From 16edc1ca78573f898135205e5b86507dee3692b2 Mon Sep 17 00:00:00 2001 From: ivan-ruban <97028192+ivan-ruban@users.noreply.github.com> Date: Thu, 1 Oct 2026 17:39:57 +0200 Subject: [PATCH] Add set_temps and actuate_trunk commands Both follow the Fleet API request format, so clients that already speak Fleet API (e.g. home automation that switches between a cloud proxy and this BLE proxy) need no changes: - set_temps: {"driver_temp": 21, "passenger_temp": 21}, Celsius 15-28, numbers or strings; passenger_temp defaults to driver_temp. Uses vehicle.ChangeClimateTemp. - actuate_trunk: {"which_trunk": "rear"|"front"}. rear toggles the trunk (vehicle.ActuateTrunk), front opens the frunk (vehicle.OpenFrunk). Invalid bodies are rejected before anything is sent to the car and are not retried. Both commands need a key with the owner role. Co-Authored-By: Claude Opus 5.5 --- README.md | 8 ++ internal/tesla/commands/commands.go | 89 ++++++++++++++++++- .../tesla/commands/commands_parse_test.go | 82 +++++++++++++++++ 3 files changed, 178 insertions(+), 1 deletion(-) create mode 100644 internal/tesla/commands/commands_parse_test.go diff --git a/README.md b/README.md index 524e09c..2492d38 100644 --- a/README.md +++ b/README.md @@ -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`. @@ -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` diff --git a/internal/tesla/commands/commands.go b/internal/tesla/commands/commands.go index 2d5a51d..1a5055e 100644 --- a/internal/tesla/commands/commands.go +++ b/internal/tesla/commands/commands.go @@ -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) { @@ -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) { @@ -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) + } +} diff --git a/internal/tesla/commands/commands_parse_test.go b/internal/tesla/commands/commands_parse_test.go new file mode 100644 index 0000000..a624e7b --- /dev/null +++ b/internal/tesla/commands/commands_parse_test.go @@ -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) + } + } +}