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:
@@ -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()
|
||||
|
||||
@@ -75,6 +75,9 @@ func TestQuickPlayJoinsFirstRace(t *testing.T) {
|
||||
s := NewService()
|
||||
_, _ = s.AddDriver("d1", "Alice", "")
|
||||
_, _ = s.AddDriver("d2", "Bob", "")
|
||||
dev1, dev2 := 1, 2
|
||||
_ = s.SelectDevice("d1", &dev1)
|
||||
_ = s.SelectDevice("d2", &dev2)
|
||||
r1, _ := s.QuickPlay("d1", 4)
|
||||
r2, err := s.QuickPlay("d2", 4)
|
||||
if err != nil {
|
||||
|
||||
@@ -65,6 +65,7 @@ type DriverMeta struct {
|
||||
LastSeenMs int64 `json:"last_seen_ms"`
|
||||
Country string `json:"country,omitempty"` // optional, for UI
|
||||
AvatarHue int `json:"avatar_hue,omitempty"` // 0..360, deterministic per id
|
||||
DeviceID *int `json:"device_id,omitempty"`
|
||||
}
|
||||
|
||||
// Snapshot is the immutable view broadcast to clients.
|
||||
@@ -290,4 +291,23 @@ func (s *Service) mirror(fn func(ctx context.Context, p Persistence) error) {
|
||||
slog.Warn("lobby persist failed", "err", err)
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
// mirrorSync is mirror but synchronous. Used for writes whose
|
||||
// successors depend on the row already being in the database (e.g.
|
||||
// AddDriver must come after UpsertRace so the FK is satisfied).
|
||||
// Failures are still logged but never block the caller: the
|
||||
// in-memory state stays consistent even when the DB write fails.
|
||||
func (s *Service) mirrorSync(fn func(ctx context.Context, p Persistence) error) {
|
||||
s.mu.RLock()
|
||||
p := s.persist
|
||||
s.mu.RUnlock()
|
||||
if p == nil {
|
||||
return
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
|
||||
defer cancel()
|
||||
if err := fn(ctx, p); err != nil {
|
||||
slog.Warn("lobby persist failed", "err", err)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user