removed device_id from all entities except car. Replaced with car_id . Car -> device_id

This commit is contained in:
2026-07-16 17:40:59 +04:00
parent 41eaf49bb2
commit f6547cd1f1
18 changed files with 126 additions and 78 deletions
+10
View File
@@ -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 ||
+1
View File
@@ -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"`
+14 -20
View File
@@ -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()
+3 -3
View File
@@ -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 {
+1 -1
View File
@@ -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.
+9 -2
View File
@@ -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
}
+2 -2
View File
@@ -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 {
+5 -5
View File
@@ -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)
@@ -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;
+9 -8
View File
@@ -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) {
+2
View File
@@ -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": "..." }.