From f6547cd1f1db3eaebea91c19b476c0e6c4f7cd3c Mon Sep 17 00:00:00 2001 From: IS1DI Date: Thu, 16 Jul 2026 17:40:59 +0400 Subject: [PATCH] removed device_id from all entities except car. Replaced with car_id . Car -> device_id --- server/cmd/poc-server/catalog_handlers.go | 7 ++-- .../cmd/poc-server/drivers_clans_handlers.go | 33 ++++++++---------- server/cmd/poc-server/main.go | 12 ++++--- server/cmd/poc-server/webrtc.go | 25 +++++++++----- server/docs/docs.go | 8 ++++- server/docs/swagger.json | 8 ++++- server/docs/swagger.yaml | 6 +++- server/internal/catalog/catalog.go | 10 ++++++ server/internal/catalog/types.go | 1 + server/internal/lobby/lobby.go | 34 ++++++++----------- server/internal/lobby/lobby_test.go | 6 ++-- server/internal/lobby/types.go | 2 +- server/internal/races/seed/seed.go | 11 ++++-- server/internal/races/service.go | 4 +-- server/internal/races/store.go | 10 +++--- .../postgres/migrations/015_driver_car_id.sql | 8 +++++ server/internal/storage/postgres/pgstore.go | 17 +++++----- server/internal/transport/codec.go | 2 ++ 18 files changed, 126 insertions(+), 78 deletions(-) create mode 100644 server/internal/storage/postgres/migrations/015_driver_car_id.sql diff --git a/server/cmd/poc-server/catalog_handlers.go b/server/cmd/poc-server/catalog_handlers.go index 338c7b9..f5855a6 100644 --- a/server/cmd/poc-server/catalog_handlers.go +++ b/server/cmd/poc-server/catalog_handlers.go @@ -189,6 +189,7 @@ func carToWire(c catalog.CarMeta) transport.CarWire { ColorHex: c.ColorHex, AvatarURL: c.AvatarURL, Active: c.Active, + DeviceID: c.DeviceID, // Stats are read-mostly; the server fills them on read. TotalDistanceM: c.TotalDistanceM, TotalRaces: c.TotalRaces, @@ -239,8 +240,9 @@ func carFromWire(w transport.CarWire) catalog.CarMeta { ColorHex: w.ColorHex, AvatarURL: w.AvatarURL, Active: w.Active, - CreatedMs: w.CreatedMs, - UpdatedMs: w.UpdatedMs, + CreatedMs: w.CreatedMs, + UpdatedMs: w.UpdatedMs, + DeviceID: w.DeviceID, } } @@ -292,6 +294,7 @@ func carPatchFromWire(p transport.CarPatch) catalog.UpdateCarOptions { Chassis: chassisFromPtr(p.Chassis), Motor: motorFromPtr(p.Motor), Battery: batteryFromPtr(p.Battery), + DeviceID: p.DeviceID, } } diff --git a/server/cmd/poc-server/drivers_clans_handlers.go b/server/cmd/poc-server/drivers_clans_handlers.go index d849af8..eab6c16 100644 --- a/server/cmd/poc-server/drivers_clans_handlers.go +++ b/server/cmd/poc-server/drivers_clans_handlers.go @@ -283,7 +283,7 @@ func driversListHandler(svc *drivers.Service) http.HandlerFunc { // @Router /api/drivers/{id} [get] // @Router /api/drivers/{id} [put] // @Router /api/drivers/{id} [delete] -// @Router /api/drivers/{id}/device [put] +// @Router /api/drivers/{id}/car [put] func driversByIDHandler(svc *drivers.Service, lobbySvc *lobby.Service) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { id := strings.TrimPrefix(r.URL.Path, "/api/drivers/") @@ -291,8 +291,8 @@ func driversByIDHandler(svc *drivers.Service, lobbySvc *lobby.Service) http.Hand writeError(w, http.StatusBadRequest, "bad_request", "missing driver id") return } - if strings.HasSuffix(id, "/device") { - driverID := strings.TrimSuffix(id, "/device") + if strings.HasSuffix(id, "/car") { + driverID := strings.TrimSuffix(id, "/car") if driverID == "" { writeError(w, http.StatusBadRequest, "bad_request", "missing driver id") return @@ -303,29 +303,26 @@ func driversByIDHandler(svc *drivers.Service, lobbySvc *lobby.Service) http.Hand return } - // Read device_id from JSON body or query param - var deviceID *int - type deviceRequest struct { - DeviceID *int `json:"device_id"` + // Read car_id from JSON body or query param + var carID *string + type carRequest struct { + CarID *string `json:"car_id"` } - var req deviceRequest + var req carRequest if r.ContentLength > 0 { if err := json.NewDecoder(r.Body).Decode(&req); err == nil { - deviceID = req.DeviceID + carID = req.CarID } } // Fallback to query parameter if body didn't provide it - if deviceID == nil { - qVal := r.URL.Query().Get("device_id") + if carID == nil { + qVal := r.URL.Query().Get("car_id") if qVal != "" { if qVal == "null" { - deviceID = nil - } else if idInt, err := strconv.Atoi(qVal); err == nil { - deviceID = &idInt + carID = nil } else { - writeError(w, http.StatusBadRequest, "bad_request", "invalid device_id query parameter") - return + carID = &qVal } } } @@ -345,8 +342,8 @@ func driversByIDHandler(svc *drivers.Service, lobbySvc *lobby.Service) http.Hand _, _ = lobbySvc.AddDriver(driverID, drv.Name, "") lobbySvc.SetDriverProfile(driverID, drv.Nickname, drv.AvatarURL, drv.ClanID, "") - // Select device in lobby (handles constraints and write-through) - if err := lobbySvc.SelectDevice(driverID, deviceID); err != nil { + // Select car in lobby (handles constraints and write-through) + if err := lobbySvc.SelectCar(driverID, carID); err != nil { writeError(w, http.StatusBadRequest, "bad_request", err.Error()) return } diff --git a/server/cmd/poc-server/main.go b/server/cmd/poc-server/main.go index 6a13dcc..bc2a5ba 100644 --- a/server/cmd/poc-server/main.go +++ b/server/cmd/poc-server/main.go @@ -199,7 +199,7 @@ func main() { driversSvc := drivers.NewService(drivers.NewPgStore(pool)) // WebRTC 1-to-1 driver-car proxy service - webrtcSvc := NewWebRTCService(logger, engine, lobbySvc, videoSvc) + webrtcSvc := NewWebRTCService(logger, engine, lobbySvc, videoSvc, cat) // Persist finished races so the /api/races list and keyset pagination // can serve historical data across restarts. The snapshot is best- @@ -577,7 +577,7 @@ func readPump(cfg *config.Config, c *realtime.Client, conn *websocket.Conn, e *c case transport.TypeClientHello: handleClientHello(c, e, cfg, lobbySvc, driversSvc, env) case transport.TypeJoinRace: - handleJoinRace(c, e, lobbySvc, env) + handleJoinRace(c, e, lobbySvc, cat, env) case transport.TypeLeaveRace: handleLeaveRace(c, e, env) case transport.TypeInputState: @@ -635,7 +635,7 @@ func handleClientHello(c *realtime.Client, e *control.Engine, cfg *config.Config c.Send <- mustEncode(transport.TypeServerHello, hello) } -func handleJoinRace(c *realtime.Client, e *control.Engine, lobbySvc *lobby.Service, env *transport.Envelope) { +func handleJoinRace(c *realtime.Client, e *control.Engine, lobbySvc *lobby.Service, cat *catalog.Service, env *transport.Envelope) { payload, _ := env.Payload.(map[string]any) slot := 0 name := "anon" @@ -654,8 +654,10 @@ func handleJoinRace(c *realtime.Client, e *control.Engine, lobbySvc *lobby.Servi driverID = c.ID } var deviceID *int - if d, err := lobbySvc.GetDriver(driverID); err == nil { - deviceID = d.DeviceID + if d, err := lobbySvc.GetDriver(driverID); err == nil && d.CarID != nil && *d.CarID != "" { + if carMeta, ok := cat.GetCar(*d.CarID); ok { + deviceID = carMeta.DeviceID + } } car, err := e.AddCar(c.ID, name, slot, deviceID) diff --git a/server/cmd/poc-server/webrtc.go b/server/cmd/poc-server/webrtc.go index 4fcfc4b..8e29670 100644 --- a/server/cmd/poc-server/webrtc.go +++ b/server/cmd/poc-server/webrtc.go @@ -11,6 +11,7 @@ import ( "time" "github.com/pion/webrtc/v4" + "github.com/x0gp/server/internal/catalog" "github.com/x0gp/server/internal/control" "github.com/x0gp/server/internal/lobby" "github.com/x0gp/server/internal/transport" @@ -31,17 +32,19 @@ type WebRTCService struct { engine *control.Engine lobbySvc *lobby.Service videoSvc *UDPVideoService + catalogSvc *catalog.Service sessions map[string]*driverSession sessionsMu sync.RWMutex } -func NewWebRTCService(logger *slog.Logger, e *control.Engine, lobbySvc *lobby.Service, videoSvc *UDPVideoService) *WebRTCService { +func NewWebRTCService(logger *slog.Logger, e *control.Engine, lobbySvc *lobby.Service, videoSvc *UDPVideoService, catalogSvc *catalog.Service) *WebRTCService { return &WebRTCService{ - logger: logger, - engine: e, - lobbySvc: lobbySvc, - videoSvc: videoSvc, - sessions: make(map[string]*driverSession), + logger: logger, + engine: e, + lobbySvc: lobbySvc, + videoSvc: videoSvc, + catalogSvc: catalogSvc, + sessions: make(map[string]*driverSession), } } @@ -93,10 +96,14 @@ func (s *WebRTCService) ConnectHandler() http.HandlerFunc { // Resolve device ID for this driver deviceID := uint8(1) // default fallback - if driver, err := s.lobbySvc.GetDriver(req.DriverID); err == nil && driver.DeviceID != nil { - deviceID = uint8(*driver.DeviceID) + if driver, err := s.lobbySvc.GetDriver(req.DriverID); err == nil && driver.CarID != nil && *driver.CarID != "" { + if car, ok := s.catalogSvc.GetCar(*driver.CarID); ok && car.DeviceID != nil { + deviceID = uint8(*car.DeviceID) + } else { + s.logger.Warn("webrtc client connected but car has no physical device_id assigned in catalog. Defaulting to device 1", "driver_id", req.DriverID, "car_id", *driver.CarID) + } } else { - s.logger.Warn("webrtc client connected but no physical device_id assigned in lobbySvc. Defaulting to device 1", "driver_id", req.DriverID) + s.logger.Warn("webrtc client connected but no car_id assigned in lobbySvc. Defaulting to device 1", "driver_id", req.DriverID) } // Create PeerConnection diff --git a/server/docs/docs.go b/server/docs/docs.go index 6448569..9e10e07 100644 --- a/server/docs/docs.go +++ b/server/docs/docs.go @@ -948,7 +948,7 @@ const docTemplate = `{ } } }, - "/api/drivers/{id}/device": { + "/api/drivers/{id}/car": { "put": { "description": "Dispatches on HTTP method. Path ` + "`" + `/api/drivers/by-nick/{nick}` + "`" + ` is a convenience that returns the driver by 3-letter nickname.", "produces": [ @@ -2242,6 +2242,9 @@ const docTemplate = `{ "color_hex": { "type": "string" }, + "device_id": { + "type": "integer" + }, "drive": { "type": "string" }, @@ -2309,6 +2312,9 @@ const docTemplate = `{ "created_ms": { "type": "integer" }, + "device_id": { + "type": "integer" + }, "drive": { "type": "string" }, diff --git a/server/docs/swagger.json b/server/docs/swagger.json index 0e8accd..e6c0ae4 100644 --- a/server/docs/swagger.json +++ b/server/docs/swagger.json @@ -946,7 +946,7 @@ } } }, - "/api/drivers/{id}/device": { + "/api/drivers/{id}/car": { "put": { "description": "Dispatches on HTTP method. Path `/api/drivers/by-nick/{nick}` is a convenience that returns the driver by 3-letter nickname.", "produces": [ @@ -2240,6 +2240,9 @@ "color_hex": { "type": "string" }, + "device_id": { + "type": "integer" + }, "drive": { "type": "string" }, @@ -2307,6 +2310,9 @@ "created_ms": { "type": "integer" }, + "device_id": { + "type": "integer" + }, "drive": { "type": "string" }, diff --git a/server/docs/swagger.yaml b/server/docs/swagger.yaml index ef4c730..64f86d0 100644 --- a/server/docs/swagger.yaml +++ b/server/docs/swagger.yaml @@ -39,6 +39,8 @@ definitions: $ref: '#/definitions/transport.ChassisWire' color_hex: type: string + device_id: + type: integer drive: type: string height_mm: @@ -83,6 +85,8 @@ definitions: type: string created_ms: type: integer + device_id: + type: integer drive: type: string height_mm: @@ -1484,7 +1488,7 @@ paths: summary: Get / update / delete a driver by id tags: - drivers - /api/drivers/{id}/device: + /api/drivers/{id}/car: put: description: Dispatches on HTTP method. Path `/api/drivers/by-nick/{nick}` is a convenience that returns the driver by 3-letter nickname. diff --git a/server/internal/catalog/catalog.go b/server/internal/catalog/catalog.go index 8878655..7bac07c 100644 --- a/server/internal/catalog/catalog.go +++ b/server/internal/catalog/catalog.go @@ -247,6 +247,7 @@ type CreateCarOptions struct { ColorHex string AvatarURL string Active *bool // optional; defaults to true + DeviceID *int } // Validate sanity-checks dimensions and required fields. @@ -319,6 +320,7 @@ func (s *Service) CreateCar(ctx context.Context, opts CreateCarOptions) (CarMeta ColorHex: opts.ColorHex, AvatarURL: opts.AvatarURL, Active: active, + DeviceID: opts.DeviceID, CreatedMs: now, UpdatedMs: now, } @@ -345,6 +347,7 @@ type UpdateCarOptions struct { Motor *MotorSpec Battery *BatterySpec Drive *Drivetrain + DeviceID *int // double pointer or *int to clear / set? Let's use *int, but to allow setting to nil (e.g. if we pass -1 or use *int), wait, actually, we can support DeviceID **int or *int. Let's use *int for now or wait, if *int is nil, it means do not update. If they want to set to a device ID, they pass a pointer. Wait! What if they want to clear it? They can pass a pointer to a negative value like -1 or we can use a double pointer. Actually, in Go, *int is very simple. Let's make it *int. If we need to clear, we can pass a device ID of -1 and map it to nil. Let's check how the JSON schema handles it. Usually *int is sufficient. Wait, let's use *int. } // UpdateCar applies a partial update. @@ -403,6 +406,13 @@ func (s *Service) UpdateCar(ctx context.Context, id string, opts UpdateCarOption if opts.Drive != nil { c.Drive = *opts.Drive } + if opts.DeviceID != nil { + if *opts.DeviceID < 0 { + c.DeviceID = nil + } else { + c.DeviceID = opts.DeviceID + } + } c.UpdatedMs = time.Now().UnixMilli() if c.LengthMm < minCarLengthMm || c.LengthMm > maxCarLengthMm || diff --git a/server/internal/catalog/types.go b/server/internal/catalog/types.go index c2f4bb9..e303529 100644 --- a/server/internal/catalog/types.go +++ b/server/internal/catalog/types.go @@ -146,6 +146,7 @@ type CarMeta struct { Motor MotorSpec `json:"motor"` Battery BatterySpec `json:"battery"` Drive Drivetrain `json:"drive"` + DeviceID *int `json:"device_id,omitempty"` // mapped physical ESP32 device ID // Performance envelope. TopSpeedMs float64 `json:"top_speed_ms"` diff --git a/server/internal/lobby/lobby.go b/server/internal/lobby/lobby.go index c7c3706..88af585 100644 --- a/server/internal/lobby/lobby.go +++ b/server/internal/lobby/lobby.go @@ -187,7 +187,7 @@ func (s *Service) RemoveDriver(id string) { d.Status = DriverStatusOffline d.LastSeenMs = time.Now().UnixMilli() d.RaceID = "" - d.DeviceID = nil + d.CarID = nil hook := s.onRaceRemoved s.mu.Unlock() @@ -244,9 +244,9 @@ func (s *Service) AddDriverToRace(driverID, raceID string) error { s.mu.Unlock() return ErrDriverNotFound } - if d.DeviceID == nil { + if d.CarID == nil || *d.CarID == "" { s.mu.Unlock() - return fmt.Errorf("driver %s has no device selected", driverID) + return fmt.Errorf("driver %s has no car selected", driverID) } r, ok := s.races[raceID] if !ok { @@ -306,7 +306,7 @@ func (s *Service) RemoveDriverFromRace(driverID string) { } d.Status = DriverStatusIdle d.RaceID = "" - d.DeviceID = nil + d.CarID = nil prevRaceID := d.RaceID hook := s.onRaceRemoved s.mu.Unlock() @@ -349,7 +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 + d.CarID = nil affected = append(affected, *d) } } @@ -454,16 +454,10 @@ 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") - } - } - +// SelectCar assigns a catalog car ID to a driver. +// If carID is nil, it clears the driver's car assignment. +// It returns an error if the car is already taken by another active driver. +func (s *Service) SelectCar(driverID string, carID *string) error { s.mu.Lock() d, ok := s.drivers[driverID] if !ok { @@ -471,17 +465,17 @@ func (s *Service) SelectDevice(driverID string, deviceID *int) error { return ErrDriverNotFound } - // Enforce that device cannot be selected if already assigned to another driver. - if deviceID != nil { + // Enforce that car cannot be selected if already assigned to another driver. + if carID != nil && *carID != "" { for otherID, otherD := range s.drivers { - if otherID != driverID && otherD.Status != DriverStatusOffline && otherD.DeviceID != nil && *otherD.DeviceID == *deviceID { + if otherID != driverID && otherD.Status != DriverStatusOffline && otherD.CarID != nil && *otherD.CarID == *carID { s.mu.Unlock() - return fmt.Errorf("device %d is already taken by driver %s", *deviceID, otherID) + return fmt.Errorf("car %s is already taken by driver %s", *carID, otherID) } } } - d.DeviceID = deviceID + d.CarID = carID out := *d s.mu.Unlock() diff --git a/server/internal/lobby/lobby_test.go b/server/internal/lobby/lobby_test.go index c2f0968..e6c6b13 100644 --- a/server/internal/lobby/lobby_test.go +++ b/server/internal/lobby/lobby_test.go @@ -75,9 +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) + car1, car2 := "car-1", "car-2" + _ = s.SelectCar("d1", &car1) + _ = s.SelectCar("d2", &car2) r1, _ := s.QuickPlay("d1", 4) r2, err := s.QuickPlay("d2", 4) if err != nil { diff --git a/server/internal/lobby/types.go b/server/internal/lobby/types.go index 77f7a30..85650e9 100644 --- a/server/internal/lobby/types.go +++ b/server/internal/lobby/types.go @@ -65,7 +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:"-"` + CarID *string `json:"car_id,omitempty"` } // Snapshot is the immutable view broadcast to clients. diff --git a/server/internal/races/seed/seed.go b/server/internal/races/seed/seed.go index bef9ebb..6743cc1 100644 --- a/server/internal/races/seed/seed.go +++ b/server/internal/races/seed/seed.go @@ -547,8 +547,15 @@ func (r *Runner) createLive(status lobby.RaceStatus, idx int) (lobby.RaceMeta, e continue } r.lb.SetDriverProfile(d.ID, d.Nickname, "", d.ClanID, d.ClanTag) - devID := i - _ = r.lb.SelectDevice(d.ID, &devID) + cars := []string{ + "f1-2024-redbull-rb20", + "f1-2024-ferrari-sf24", + "f1-2024-mclaren-mcl38", + "f1-2024-mercedes-w15", + "f1-2024-astonmartin-amr24", + } + carID := cars[i%len(cars)] + _ = r.lb.SelectCar(d.ID, &carID) if err := r.lb.AddDriverToRace(d.ID, meta.ID); err != nil { // ignore: race may be at capacity } diff --git a/server/internal/races/service.go b/server/internal/races/service.go index 033b1ec..4abe847 100644 --- a/server/internal/races/service.go +++ b/server/internal/races/service.go @@ -62,8 +62,8 @@ 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) + if d.CarID != nil { + _ = s.lobby.SelectCar(d.ID, d.CarID) } } for _, r := range races { diff --git a/server/internal/races/store.go b/server/internal/races/store.go index ba24d88..5df7fae 100644 --- a/server/internal/races/store.go +++ b/server/internal/races/store.go @@ -391,7 +391,7 @@ func (s *PgStore) UpsertLobbyDriver(ctx context.Context, d lobby.DriverMeta) err _, err := s.pool.Exec(ctx, ` INSERT INTO lobby_drivers (driver_id, name, nickname, avatar_url, clan_id, clan_tag, - status, current_race_id, connected_ms, last_seen_ms, device_id) + status, current_race_id, connected_ms, last_seen_ms, car_id) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11) ON CONFLICT (driver_id) DO UPDATE SET name = EXCLUDED.name, @@ -402,9 +402,9 @@ func (s *PgStore) UpsertLobbyDriver(ctx context.Context, d lobby.DriverMeta) err status = EXCLUDED.status, current_race_id = EXCLUDED.current_race_id, last_seen_ms = EXCLUDED.last_seen_ms, - device_id = EXCLUDED.device_id`, + car_id = EXCLUDED.car_id`, d.ID, d.Name, d.Nickname, d.AvatarURL, d.ClanID, d.ClanTag, - string(d.Status), d.RaceID, d.ConnectedMs, d.LastSeenMs, d.DeviceID) + string(d.Status), d.RaceID, d.ConnectedMs, d.LastSeenMs, d.CarID) if err != nil { return fmt.Errorf("upsert lobby driver %s: %w", d.ID, err) } @@ -421,7 +421,7 @@ func (s *PgStore) DeleteLobbyDriver(ctx context.Context, driverID string) error func (s *PgStore) ListLobbyDrivers(ctx context.Context) ([]lobby.DriverMeta, error) { rows, err := s.pool.Query(ctx, ` SELECT driver_id, name, nickname, avatar_url, clan_id, clan_tag, - status, current_race_id, connected_ms, last_seen_ms, device_id + status, current_race_id, connected_ms, last_seen_ms, car_id FROM lobby_drivers ORDER BY last_seen_ms DESC`) if err != nil { return nil, err @@ -432,7 +432,7 @@ func (s *PgStore) ListLobbyDrivers(ctx context.Context) ([]lobby.DriverMeta, err var d lobby.DriverMeta var status string if err := rows.Scan(&d.ID, &d.Name, &d.Nickname, &d.AvatarURL, &d.ClanID, &d.ClanTag, - &status, &d.RaceID, &d.ConnectedMs, &d.LastSeenMs, &d.DeviceID); err != nil { + &status, &d.RaceID, &d.ConnectedMs, &d.LastSeenMs, &d.CarID); err != nil { return nil, err } d.Status = lobby.DriverStatus(status) diff --git a/server/internal/storage/postgres/migrations/015_driver_car_id.sql b/server/internal/storage/postgres/migrations/015_driver_car_id.sql new file mode 100644 index 0000000..9574b39 --- /dev/null +++ b/server/internal/storage/postgres/migrations/015_driver_car_id.sql @@ -0,0 +1,8 @@ +-- 015_driver_car_id.sql — move device_id to cars table and use car_id in lobby_drivers +-- +-- Represents the currently selected physical ESP32 DEVICE_ID (0..127) for a car. +-- And represents the currently selected car ID for a driver in lobby_drivers. + +ALTER TABLE cars ADD COLUMN IF NOT EXISTS device_id INT; +ALTER TABLE lobby_drivers ADD COLUMN IF NOT EXISTS car_id TEXT; +ALTER TABLE lobby_drivers DROP COLUMN IF EXISTS device_id; diff --git a/server/internal/storage/postgres/pgstore.go b/server/internal/storage/postgres/pgstore.go index 7ba0a2f..1ea16e5 100644 --- a/server/internal/storage/postgres/pgstore.go +++ b/server/internal/storage/postgres/pgstore.go @@ -142,7 +142,7 @@ func (s *PgStore) loadCars(ctx context.Context) ([]catalog.CarMeta, error) { battery_voltage_v, battery_capacity_mah, battery_cells, battery_chemistry, drive, top_speed_ms, color_hex, avatar_url, active, total_distance_m, total_races, total_laps, best_lap_ms, - created_ms, updated_ms + created_ms, updated_ms, device_id FROM cars ORDER BY id`) if err != nil { @@ -161,7 +161,7 @@ func (s *PgStore) loadCars(ctx context.Context) ([]catalog.CarMeta, error) { &c.Battery.VoltageV, &c.Battery.CapacityMah, &c.Battery.Cells, &c.Battery.Chemistry, &c.Drive, &c.TopSpeedMs, &c.ColorHex, &c.AvatarURL, &c.Active, &c.TotalDistanceM, &c.TotalRaces, &c.TotalLaps, &c.BestLapMs, - &c.CreatedMs, &c.UpdatedMs, + &c.CreatedMs, &c.UpdatedMs, &c.DeviceID, ); err != nil { return nil, err } @@ -260,8 +260,8 @@ func (s *PgStore) UpsertCar(ctx context.Context, c catalog.CarMeta) error { battery_voltage_v, battery_capacity_mah, battery_cells, battery_chemistry, drive, top_speed_ms, color_hex, avatar_url, active, total_distance_m, total_races, total_laps, best_lap_ms, - created_ms, updated_ms) - VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16,$17,$18,$19,$20,$21,$22,$23,$24,$25,$26,$27,$28,$29,$30,$31,$32) + created_ms, updated_ms, device_id) + VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16,$17,$18,$19,$20,$21,$22,$23,$24,$25,$26,$27,$28,$29,$30,$31,$32,$33) ON CONFLICT (id) DO UPDATE SET name = EXCLUDED.name, owner_id = EXCLUDED.owner_id, @@ -293,7 +293,8 @@ func (s *PgStore) UpsertCar(ctx context.Context, c catalog.CarMeta) error { total_races = EXCLUDED.total_races, total_laps = EXCLUDED.total_laps, best_lap_ms = EXCLUDED.best_lap_ms, - updated_ms = EXCLUDED.updated_ms`, + updated_ms = EXCLUDED.updated_ms, + device_id = EXCLUDED.device_id`, c.ID, c.Name, c.OwnerID, string(c.Visibility), c.Scale, c.LengthMm, c.WidthMm, c.HeightMm, c.WeightG, c.Chassis.Model, c.Chassis.Material, c.Chassis.Printed, @@ -302,7 +303,7 @@ func (s *PgStore) UpsertCar(ctx context.Context, c catalog.CarMeta) error { c.Battery.VoltageV, c.Battery.CapacityMah, c.Battery.Cells, c.Battery.Chemistry, string(c.Drive), c.TopSpeedMs, c.ColorHex, c.AvatarURL, c.Active, c.TotalDistanceM, c.TotalRaces, c.TotalLaps, c.BestLapMs, - c.CreatedMs, c.UpdatedMs, + c.CreatedMs, c.UpdatedMs, c.DeviceID, ) return err } @@ -333,7 +334,7 @@ func (s *PgStore) UpdateCarStats(ctx context.Context, id string, fn func(*catalo battery_voltage_v, battery_capacity_mah, battery_cells, battery_chemistry, drive, top_speed_ms, color_hex, avatar_url, active, total_distance_m, total_races, total_laps, best_lap_ms, - created_ms, updated_ms + created_ms, updated_ms, device_id FROM cars WHERE id = $1 FOR UPDATE`, id, ).Scan( &c.ID, &c.Name, &c.OwnerID, &c.Visibility, &c.Scale, @@ -344,7 +345,7 @@ func (s *PgStore) UpdateCarStats(ctx context.Context, id string, fn func(*catalo &c.Battery.VoltageV, &c.Battery.CapacityMah, &c.Battery.Cells, &c.Battery.Chemistry, &c.Drive, &c.TopSpeedMs, &c.ColorHex, &c.AvatarURL, &c.Active, &c.TotalDistanceM, &c.TotalRaces, &c.TotalLaps, &c.BestLapMs, - &c.CreatedMs, &c.UpdatedMs, + &c.CreatedMs, &c.UpdatedMs, &c.DeviceID, ) if err != nil { if errors.Is(err, pgx.ErrNoRows) { diff --git a/server/internal/transport/codec.go b/server/internal/transport/codec.go index cfcbe93..4dae091 100644 --- a/server/internal/transport/codec.go +++ b/server/internal/transport/codec.go @@ -478,6 +478,7 @@ type CarWire struct { CreatedMs int64 `json:"created_ms"` UpdatedMs int64 `json:"updated_ms"` + DeviceID *int `json:"device_id,omitempty"` } // CarListRequest: payload { "scope": "all|system|public|private", "owner_id": "..." }. @@ -519,6 +520,7 @@ type CarPatch struct { ColorHex *string `json:"color_hex,omitempty"` AvatarURL *string `json:"avatar_url,omitempty"` Active *bool `json:"active,omitempty"` + DeviceID *int `json:"device_id,omitempty"` } // CarDeleteRequest: payload { "id": "..." }.