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
+104 -20
View File
@@ -62,6 +62,9 @@ func (s *Service) RestoreFromDB(ctx context.Context) (int, int, error) {
// AddDriver+SetDriverProfile and accept a no-op mirror.
_, _ = s.lobby.AddDriver(d.ID, d.Name, "")
s.lobby.SetDriverProfile(d.ID, d.Nickname, d.AvatarURL, d.ClanID, d.ClanTag)
if d.DeviceID != nil {
_ = s.lobby.SelectDevice(d.ID, d.DeviceID)
}
}
for _, r := range races {
// We bypass CreateRace to avoid duplicating persistence writes
@@ -106,6 +109,7 @@ type RaceItem struct {
type ListResult struct {
Items []RaceItem
NextCursor string // empty when no more pages
TotalCount int
}
// ListRaces returns one keyset page of races matching the filter.
@@ -186,7 +190,34 @@ func (s *Service) ListRaces(ctx context.Context, f ListFilter) (ListResult, erro
out = out[:limit]
}
res := ListResult{Items: out}
var totalCount int
sqlStatuses := getSQLStatuses(f.Statuses)
if s.live != nil {
var err error
totalCount, err = s.pg.CountRaces(ctx, sqlStatuses, f.TrackID)
if err != nil {
return ListResult{}, err
}
} else {
// Fallback: in-memory live + pg finished
liveCount := 0
for _, r := range s.lobby.ListRaces() {
if statusMatches(f.Statuses, string(r.Status)) && (f.TrackID == "" || r.TrackID == f.TrackID) {
liveCount++
}
}
var finishedCount int
if statusWantsFinished(f.Statuses) {
var err error
finishedCount, err = s.pg.CountRaces(ctx, []string{"finished", "cancelled"}, f.TrackID)
if err != nil {
return ListResult{}, err
}
}
totalCount = liveCount + finishedCount
}
res := ListResult{Items: out, TotalCount: totalCount}
if hasMore && len(out) > 0 {
last := out[len(out)-1]
res.NextCursor = Cursor{
@@ -292,6 +323,37 @@ func filterToStatusStrings(filters []StatusFilter) []string {
return out
}
func getSQLStatuses(filters []StatusFilter) []string {
var sqlStatuses []string
wantLive := false
wantFinished := false
if len(filters) == 0 {
wantLive = true
wantFinished = true
} else {
for _, f := range filters {
if f == StatusAll {
wantLive = true
wantFinished = true
break
}
if f.matchesLive("finished") || f == StatusFinished {
wantFinished = true
}
if f.matchesLive("lobby") || f == StatusLobby || f == StatusRacing {
wantLive = true
}
}
}
if wantLive {
sqlStatuses = append(sqlStatuses, filterToStatusStrings(filters)...)
}
if wantFinished {
sqlStatuses = append(sqlStatuses, "finished", "cancelled")
}
return sqlStatuses
}
func (s *Service) collectFinished(ctx context.Context, filters []StatusFilter, trackID, curSource string, curMs int64, curID string, limit int) ([]RaceItem, error) {
if !statusWantsFinished(filters) {
return nil, nil
@@ -488,8 +550,11 @@ func (s *Service) ListQueue(ctx context.Context, driverID string) ([]QueueEntry,
// Finished-race snapshot hook
// ---------------------------------------------------------------------------
// SnapshotFinished is the input to the "persist on finish" hook. Race is
// a copy of the in-memory meta. Result is filled by the engine.
// SnapshotFinished is the input to the "persist on finish" hook.
// The engine fills Meta, FinishedMs, TotalLaps and either:
// * Results (full per-driver totals / positions), or
// * the legacy WinnerDriverID + BestLapMs fallback (used by the
// seeder and any caller that doesn't track per-driver times).
type SnapshotFinished struct {
Meta lobby.RaceMeta
FinishedMs int64
@@ -497,6 +562,7 @@ type SnapshotFinished struct {
WinnerDriverID string
WinnerName string
BestLapMs int64
Results []DriverResult
}
// PersistFinished upserts the finished race into Postgres.
@@ -506,24 +572,37 @@ func (s *Service) PersistFinished(ctx context.Context, sf SnapshotFinished) erro
if finishedMs == 0 {
finishedMs = s.now().UnixMilli()
}
// Build the per-driver results from the in-memory race meta and
// the snapshot. In PoC we don't have per-driver totals from the
// engine yet, so we use the snapshot's WinnerDriverID as a
// single-row fallback when Results is empty. The full per-driver
// totals will be supplied by the engine in a future iteration.
results := sf.Results
if len(results) == 0 && sf.WinnerDriverID != "" {
pos := 1
results = []DriverResult{{
DriverID: sf.WinnerDriverID,
TotalTimeMs: finishedMs - startedMs,
BestLapMs: sf.BestLapMs,
Position: &pos,
}}
}
r := FinishedRace{
ID: sf.Meta.ID,
Name: sf.Meta.Name,
TrackID: sf.Meta.TrackID,
MaxCars: sf.Meta.MaxCars,
Laps: sf.Meta.Laps,
TimeLimitS: sf.Meta.TimeLimitS,
DriverIDs: append([]string(nil), sf.Meta.DriverIDs...),
Status: "finished",
CreatedMs: sf.Meta.CreatedMs,
StartedMs: startedMs,
FinishedMs: finishedMs,
DurationMs: finishedMs - startedMs,
TotalLaps: sf.TotalLaps,
TotalDrivers: len(sf.Meta.DriverIDs),
WinnerDriverID: sf.WinnerDriverID,
WinnerName: sf.WinnerName,
BestLapMs: sf.BestLapMs,
ID: sf.Meta.ID,
Name: sf.Meta.Name,
TrackID: sf.Meta.TrackID,
MaxCars: sf.Meta.MaxCars,
Laps: sf.Meta.Laps,
TimeLimitS: sf.Meta.TimeLimitS,
DriverIDs: append([]string(nil), sf.Meta.DriverIDs...),
Status: "finished",
CreatedMs: sf.Meta.CreatedMs,
StartedMs: startedMs,
FinishedMs: finishedMs,
DurationMs: finishedMs - startedMs,
TotalLaps: sf.TotalLaps,
TotalDrivers: len(sf.Meta.DriverIDs),
Results: results,
}
return s.pg.InsertFinished(ctx, r)
}
@@ -596,6 +675,11 @@ func (s *Service) ListPlans(ctx context.Context, cur Cursor, limit int) ([]RaceP
return s.pg.ListPlans(ctx, cur, limit)
}
// CountPlans returns the total number of race plans.
func (s *Service) CountPlans(ctx context.Context) (int, error) {
return s.pg.CountPlans(ctx)
}
// DeletePlan removes a plan by id.
func (s *Service) DeletePlan(ctx context.Context, id string) error {
return s.pg.DeletePlan(ctx, id)