Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -145,6 +145,8 @@ The program uses the same interfaces as the Tesla [Fleet API](https://developer.
- door_lock
- door_unlock
- set_sentry_mode
- set_temps
- actuate_trunk

By default, the program will return immediately after sending the command to the vehicle. If you want to wait for the command to complete, you can set the `wait` parameter to `true`.

Expand All @@ -166,6 +168,12 @@ Stop charging:
Set charging amps to 5A:
`http://localhost:8080/api/1/vehicles/{VIN}/command/set_charging_amps` with body `{"charging_amps": "5"}`

Set cabin temperature to 21 °C (Celsius, 15-28; `passenger_temp` is optional and defaults to `driver_temp`):
`http://localhost:8080/api/1/vehicles/{VIN}/command/set_temps` with body `{"driver_temp": 21, "passenger_temp": 21}`

Open or close the rear trunk (`front` opens the frunk; it cannot be closed remotely):
`http://localhost:8080/api/1/vehicles/{VIN}/command/actuate_trunk` with body `{"which_trunk": "rear"}`

Explicitly wake up the vehicle:
`http://localhost:8080/api/1/vehicles/{VIN}/command/wake_up`

Expand Down
89 changes: 88 additions & 1 deletion internal/tesla/commands/commands.go
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ import (
"github.com/wimaha/TeslaBleHttpProxy/internal/logging"
)

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 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", "set_temps", "actuate_trunk"}
var ExceptedEndpoints = []string{"charge_state", "climate_state"}

func (command *Command) Send(ctx context.Context, car *vehicle.Vehicle) (shouldRetry bool, err error) {
Expand Down Expand Up @@ -57,6 +57,28 @@ func (command *Command) Send(ctx context.Context, car *vehicle.Vehicle) (shouldR
if err := car.Unlock(ctx); err != nil {
return true, fmt.Errorf("failed to unlock %s", err)
}
case "set_temps":
driverTemp, passengerTemp, err := parseTemps(command.Body)
if err != nil {
return false, err
}
if err := car.ChangeClimateTemp(ctx, driverTemp, passengerTemp); err != nil {
return true, fmt.Errorf("failed to set temps to %.1f/%.1f: %s", driverTemp, passengerTemp, err)
}
case "actuate_trunk":
whichTrunk, err := parseWhichTrunk(command.Body)
if err != nil {
return false, err
}
if whichTrunk == "front" {
if err := car.OpenFrunk(ctx); err != nil {
return true, fmt.Errorf("failed to open frunk: %s", err)
}
} else {
if err := car.ActuateTrunk(ctx); err != nil {
return true, fmt.Errorf("failed to actuate trunk: %s", err)
}
}
case "set_sentry_mode":
var on bool
switch v := command.Body["on"].(type) {
Expand Down Expand Up @@ -271,3 +293,68 @@ func (command *Command) Send(ctx context.Context, car *vehicle.Vehicle) (shouldR
// everything fine
return false, nil
}

// parseFloatField reads a numeric body field that may arrive as a JSON number or a string.
func parseFloatField(body map[string]interface{}, key string) (float32, bool, error) {
raw, ok := body[key]
if !ok || raw == nil {
return 0, false, nil
}
switch v := raw.(type) {
case float64:
return float32(v), true, nil
case string:
f, err := strconv.ParseFloat(strings.TrimSpace(v), 32)
if err != nil {
return 0, true, fmt.Errorf("%s parsing error: %s", key, err)
}
return float32(f), true, nil
default:
return 0, true, fmt.Errorf("%s has unsupported type %T", key, raw)
}
}

// parseTemps reads the Fleet API set_temps body: {"driver_temp": 21, "passenger_temp": 21} in Celsius.
// passenger_temp is optional and defaults to driver_temp.
func parseTemps(body map[string]interface{}) (float32, float32, error) {
driver, found, err := parseFloatField(body, "driver_temp")
if err != nil {
return 0, 0, err
}
if !found {
return 0, 0, fmt.Errorf("driver_temp missing in body")
}
passenger, found, err := parseFloatField(body, "passenger_temp")
if err != nil {
return 0, 0, err
}
if !found {
passenger = driver
}
for _, t := range []float32{driver, passenger} {
if t < 15 || t > 28 {
return 0, 0, fmt.Errorf("temperature %.1f out of range 15-28 °C", t)
}
}
return driver, passenger, nil
}

// parseWhichTrunk reads the Fleet API actuate_trunk body: {"which_trunk": "rear"} or {"which_trunk": "front"}.
func parseWhichTrunk(body map[string]interface{}) (string, error) {
raw, ok := body["which_trunk"]
if !ok || raw == nil {
return "", fmt.Errorf("which_trunk missing in body")
}
s, ok := raw.(string)
if !ok {
return "", fmt.Errorf("which_trunk has unsupported type %T", raw)
}
switch strings.ToLower(strings.TrimSpace(s)) {
case "rear":
return "rear", nil
case "front":
return "front", nil
default:
return "", fmt.Errorf("which_trunk must be \"rear\" or \"front\", got %q", s)
}
}
82 changes: 82 additions & 0 deletions internal/tesla/commands/commands_parse_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
package commands

import (
"context"
"testing"

"github.com/teslamotors/vehicle-command/pkg/vehicle"
)

func TestParseTemps(t *testing.T) {
tests := []struct {
name string
body map[string]interface{}
wantDriver float32
wantPassenger float32
wantErr bool
}{
{"numbers", map[string]interface{}{"driver_temp": 21.5, "passenger_temp": 20.0}, 21.5, 20, false},
{"strings", map[string]interface{}{"driver_temp": "22", "passenger_temp": " 19.5 "}, 22, 19.5, false},
{"passenger defaults to driver", map[string]interface{}{"driver_temp": 21.0}, 21, 21, false},
{"range bounds inclusive", map[string]interface{}{"driver_temp": 15.0, "passenger_temp": 28.0}, 15, 28, false},
{"nil body", nil, 0, 0, true},
{"driver missing", map[string]interface{}{"passenger_temp": 21.0}, 0, 0, true},
{"driver not a number", map[string]interface{}{"driver_temp": "warm"}, 0, 0, true},
{"unsupported type", map[string]interface{}{"driver_temp": true}, 0, 0, true},
{"driver too high", map[string]interface{}{"driver_temp": 200.0}, 0, 0, true},
{"passenger too low", map[string]interface{}{"driver_temp": 21.0, "passenger_temp": 5.0}, 0, 0, true},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
d, p, err := parseTemps(tt.body)
if (err != nil) != tt.wantErr {
t.Fatalf("err = %v, wantErr %v", err, tt.wantErr)
}
if err == nil && (d != tt.wantDriver || p != tt.wantPassenger) {
t.Errorf("got %.1f/%.1f, want %.1f/%.1f", d, p, tt.wantDriver, tt.wantPassenger)
}
})
}
}

func TestParseWhichTrunk(t *testing.T) {
tests := []struct {
name string
body map[string]interface{}
want string
wantErr bool
}{
{"rear", map[string]interface{}{"which_trunk": "rear"}, "rear", false},
{"front", map[string]interface{}{"which_trunk": "front"}, "front", false},
{"case and spaces", map[string]interface{}{"which_trunk": " REAR "}, "rear", false},
{"nil body", nil, "", true},
{"missing", map[string]interface{}{}, "", true},
{"unknown value", map[string]interface{}{"which_trunk": "side"}, "", true},
{"unsupported type", map[string]interface{}{"which_trunk": 1.0}, "", true},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got, err := parseWhichTrunk(tt.body)
if (err != nil) != tt.wantErr {
t.Fatalf("err = %v, wantErr %v", err, tt.wantErr)
}
if got != tt.want {
t.Errorf("got %q, want %q", got, tt.want)
}
})
}
}

// Invalid bodies must be rejected before anything is sent to the car, and must not be retried.
func TestInvalidBodyNotRetried(t *testing.T) {
for _, cmd := range []string{"set_temps", "actuate_trunk"} {
command := &Command{Command: cmd, Body: map[string]interface{}{}}
retry, err := command.Send(context.Background(), &vehicle.Vehicle{})
if err == nil {
t.Errorf("%s with empty body: expected error", cmd)
}
if retry {
t.Errorf("%s with empty body: shouldRetry = true, want false", cmd)
}
}
}