diff --git a/README.md b/README.md index 524e09c..d13c60d 100644 --- a/README.md +++ b/README.md @@ -187,15 +187,18 @@ Get vehicle data: Get vehicle data with automatic wakeup: `http://localhost:8080/api/1/vehicles/{VIN}/vehicle_data?wakeup=true` -Currently you will receive the following data: +By default you will receive the following data: - charge_state - climate_state -If you want to receive specific data, you can add the endpoints to the request. For example: +If you want to receive specific data, you can add the endpoints to the request. This also lets you request additional endpoints that are not part of the default response, such as `drive_state` (which includes the `odometer` field in miles, delivered over BLE in the drive state). For example: `http://localhost:8080/api/1/vehicles/{VIN}/vehicle_data?endpoints=charge_state` +Request the drive state including the odometer: +`http://localhost:8080/api/1/vehicles/{VIN}/vehicle_data?endpoints=drive_state` + Get specific data with automatic wakeup: `http://localhost:8080/api/1/vehicles/{VIN}/vehicle_data?endpoints=charge_state&wakeup=true` diff --git a/internal/api/models/states.go b/internal/api/models/states.go index 881e637..d6c1ef6 100644 --- a/internal/api/models/states.go +++ b/internal/api/models/states.go @@ -56,6 +56,18 @@ type ChargeState struct { MinutesToFullCharge int32 `json:"minutes_to_full_charge"` // } +// DriveState contains the current drive states available from the vehicle. +type DriveState struct { + Timestamp int64 `json:"timestamp"` // + ShiftState string `json:"shift_state"` // + Speed float32 `json:"speed"` // + Power int32 `json:"power"` // + // Odometer is expressed in miles. The Tesla Fleet API historically returns + // the odometer in vehicle_state, but the BLE protocol delivers it in + // DriveState, so it is exposed here. + Odometer float64 `json:"odometer"` // +} + // ClimateState contains the current climate states available from the vehicle. type ClimateState struct { Timestamp int64 `json:"timestamp"` // diff --git a/internal/api/models/statesConverter.go b/internal/api/models/statesConverter.go index c93e280..767080a 100644 --- a/internal/api/models/statesConverter.go +++ b/internal/api/models/statesConverter.go @@ -73,6 +73,54 @@ MISSING OffPeakChargingEnabled bool `json:"off_peak_charging_enabled"` */ +// shiftStateFromBle converts the DriveState shift_state oneof into the string +// representation used by the Tesla Fleet API ("P", "R", "N", "D"). An unset or +// invalid shift state is returned as an empty string. +func shiftStateFromBle(shiftState *carserver.ShiftState) string { + switch shiftState.GetType().(type) { + case *carserver.ShiftState_P: + return "P" + case *carserver.ShiftState_R: + return "R" + case *carserver.ShiftState_N: + return "N" + case *carserver.ShiftState_D: + return "D" + case *carserver.ShiftState_SNA: + // SNA = signal not available; treat it like an unset shift state. + return "" + default: + return "" + } +} + +// speedFromBle returns the vehicle speed, preferring the newer speed_float +// field. When speed_float is not present it falls back to the older integer +// speed field. Presence is detected via the optional oneof accessors, since a +// value of 0 cannot otherwise be distinguished from an unset field. +func speedFromBle(driveState *carserver.DriveState) float32 { + if driveState.GetOptionalSpeedFloat() != nil { + return driveState.GetSpeedFloat() + } + if driveState.GetOptionalSpeed() != nil { + return float32(driveState.GetSpeed()) + } + return 0 +} + +func DriveStateFromBle(VehicleData *carserver.VehicleData) DriveState { + return DriveState{ + Timestamp: VehicleData.DriveState.GetTimestamp().AsTime().Unix(), + ShiftState: shiftStateFromBle(VehicleData.DriveState.GetShiftState()), + Speed: speedFromBle(VehicleData.DriveState), + Power: VehicleData.DriveState.GetPower(), + // The Fleet API historically exposes the odometer in vehicle_state, but + // the BLE protocol delivers it in DriveState as hundredths of a mile, so + // convert it to miles and expose it here. + Odometer: float64(VehicleData.DriveState.GetOdometerInHundredthsOfAMile()) / 100.0, + } +} + func ClimateStateFromBle(VehicleData *carserver.VehicleData) ClimateState { return ClimateState{ Timestamp: VehicleData.ClimateState.GetTimestamp().AsTime().Unix(), diff --git a/internal/api/models/statesConverter_test.go b/internal/api/models/statesConverter_test.go new file mode 100644 index 0000000..bc7d1e8 --- /dev/null +++ b/internal/api/models/statesConverter_test.go @@ -0,0 +1,101 @@ +package models + +import ( + "testing" + + "github.com/teslamotors/vehicle-command/pkg/protocol/protobuf/carserver" +) + +func TestDriveStateFromBleOdometer(t *testing.T) { + vehicleData := &carserver.VehicleData{ + DriveState: &carserver.DriveState{ + OptionalOdometerInHundredthsOfAMile: &carserver.DriveState_OdometerInHundredthsOfAMile{ + OdometerInHundredthsOfAMile: 1234567, + }, + OptionalPower: &carserver.DriveState_Power{ + Power: 42, + }, + OptionalSpeedFloat: &carserver.DriveState_SpeedFloat{ + SpeedFloat: 12.5, + }, + ShiftState: &carserver.ShiftState{ + Type: &carserver.ShiftState_D{D: &carserver.Void{}}, + }, + }, + } + + got := DriveStateFromBle(vehicleData) + + if got.Odometer != 12345.67 { + t.Errorf("Odometer = %v, want %v", got.Odometer, 12345.67) + } + if got.Power != 42 { + t.Errorf("Power = %v, want %v", got.Power, 42) + } + if got.Speed != 12.5 { + t.Errorf("Speed = %v, want %v", got.Speed, 12.5) + } + if got.ShiftState != "D" { + t.Errorf("ShiftState = %q, want %q", got.ShiftState, "D") + } +} + +func TestShiftStateFromBle(t *testing.T) { + tests := []struct { + name string + input *carserver.ShiftState + want string + }{ + {"nil", nil, ""}, + {"unset", &carserver.ShiftState{}, ""}, + {"invalid", &carserver.ShiftState{Type: &carserver.ShiftState_Invalid{Invalid: &carserver.Void{}}}, ""}, + {"park", &carserver.ShiftState{Type: &carserver.ShiftState_P{P: &carserver.Void{}}}, "P"}, + {"reverse", &carserver.ShiftState{Type: &carserver.ShiftState_R{R: &carserver.Void{}}}, "R"}, + {"neutral", &carserver.ShiftState{Type: &carserver.ShiftState_N{N: &carserver.Void{}}}, "N"}, + {"drive", &carserver.ShiftState{Type: &carserver.ShiftState_D{D: &carserver.Void{}}}, "D"}, + {"sna", &carserver.ShiftState{Type: &carserver.ShiftState_SNA{SNA: &carserver.Void{}}}, ""}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := shiftStateFromBle(tt.input); got != tt.want { + t.Errorf("shiftStateFromBle(%s) = %q, want %q", tt.name, got, tt.want) + } + }) + } +} + +func TestDriveStateFromBleSpeedFallback(t *testing.T) { + // When speed_float is not present, the conversion must fall back to the older + // integer speed field. + vehicleData := &carserver.VehicleData{ + DriveState: &carserver.DriveState{ + OptionalSpeed: &carserver.DriveState_Speed{ + Speed: 37, + }, + }, + } + + got := DriveStateFromBle(vehicleData) + + if got.Speed != 37 { + t.Errorf("Speed = %v, want %v", got.Speed, 37) + } +} + +func TestDriveStateFromBleMissingOptionalOdometer(t *testing.T) { + // When the optional odometer is not present, the conversion must default to 0 + // instead of panicking or returning an unexpected value. + vehicleData := &carserver.VehicleData{ + DriveState: &carserver.DriveState{}, + } + + got := DriveStateFromBle(vehicleData) + + if got.Odometer != 0 { + t.Errorf("Odometer = %v, want %v", got.Odometer, 0) + } + if got.ShiftState != "" { + t.Errorf("ShiftState = %q, want empty string", got.ShiftState) + } +} diff --git a/internal/tesla/commands/command.go b/internal/tesla/commands/command.go index 5832d94..8653989 100644 --- a/internal/tesla/commands/command.go +++ b/internal/tesla/commands/command.go @@ -34,6 +34,7 @@ var categoriesByName = map[string]vehicle.StateCategory{ "charge_state": vehicle.StateCategoryCharge, "climate_state": vehicle.StateCategoryClimate, "drive": vehicle.StateCategoryDrive, + "drive_state": vehicle.StateCategoryDrive, "closures_state": vehicle.StateCategoryClosures, "charge-schedule": vehicle.StateCategoryChargeSchedule, "precondition-schedule": vehicle.StateCategoryPreconditioningSchedule, diff --git a/internal/tesla/commands/commands.go b/internal/tesla/commands/commands.go index 2d5a51d..e459dcc 100644 --- a/internal/tesla/commands/commands.go +++ b/internal/tesla/commands/commands.go @@ -17,7 +17,7 @@ import ( ) var ExceptedCommands = []string{"vehicle_data", "auto_conditioning_start", "auto_conditioning_stop", "charge_port_door_open", "charge_port_door_close", "flash_lights", "wake_up", "set_charging_amps", "set_charge_limit", "charge_start", "charge_stop", "session_info", "honk_horn", "door_lock", "door_unlock", "set_sentry_mode"} -var ExceptedEndpoints = []string{"charge_state", "climate_state"} +var ExceptedEndpoints = []string{"charge_state", "climate_state", "drive_state"} func (command *Command) Send(ctx context.Context, car *vehicle.Vehicle) (shouldRetry bool, err error) { switch command.Command { @@ -240,6 +240,8 @@ func (command *Command) Send(ctx context.Context, car *vehicle.Vehicle) (shouldR converted = models.ChargeStateFromBle(data) case "climate_state": converted = models.ClimateStateFromBle(data) + case "drive_state": + converted = models.DriveStateFromBle(data) } d, err := json.Marshal(converted) if err != nil {