diff --git a/examples/unlock/unlock.go b/examples/unlock/unlock.go index 29754dd..13656da 100644 --- a/examples/unlock/unlock.go +++ b/examples/unlock/unlock.go @@ -32,7 +32,7 @@ func main() { // Specify the user-agent header value used in HTTP requests to Tesla's servers. The default // value is constructed from your package name and account.LibraryVersion. - account.UserAgent = "example-unlock/1.0.0" + userAgent := "example-unlock/1.0.0" if vin == "" { logger.Printf("Must specify VIN") @@ -61,7 +61,7 @@ func main() { // This example program sends commands over the Internet, which requires a Tesla account login // token. The protocol can also work over BLE; see other programs in the example directory. - acct, err := account.New(string(oauthToken)) + acct, err := account.New(string(oauthToken), userAgent) if err != nil { logger.Printf("Authentication error: %s", err) return diff --git a/pkg/account/account.go b/pkg/account/account.go index 57d395b..e038a49 100644 --- a/pkg/account/account.go +++ b/pkg/account/account.go @@ -24,13 +24,9 @@ import ( var ( //go:embed version.txt libraryVersion string - // UserAgent is sent in HTTP requests. - // - // The default value is "
/ tesla-sdk/". - UserAgent = buildUserAgent() ) -func buildUserAgent() string { +func buildUserAgent(app string) string { library := strings.TrimSpace("tesla-sdk/" + libraryVersion) build, ok := debug.ReadBuildInfo() if !ok { @@ -40,26 +36,29 @@ func buildUserAgent() string { if len(path) == 0 { return library } - app := path[len(path)-1] - - var version string - if build.Main.Version != "(devel)" && build.Main.Version != "" { - version = build.Main.Version - } else { - for _, info := range build.Settings { - if info.Key == "vcs.revision" { - if len(info.Value) > 8 { - version = info.Value[0:8] + + if app == "" { + app = path[len(path)-1] + var version string + if build.Main.Version != "(devel)" && build.Main.Version != "" { + version = build.Main.Version + } else { + for _, info := range build.Settings { + if info.Key == "vcs.revision" { + if len(info.Value) > 8 { + version = info.Value[0:8] + } + break } - break } } - } - if version == "" { - return fmt.Sprintf("%s %s", app, library) + if version != "" { + app = fmt.Sprintf("%s/%s", app, version) + } } - return fmt.Sprintf("%s/%s %s", app, version, library) + + return fmt.Sprintf("%s %s", app, library) } // Account allows interaction with a Tesla account. @@ -114,7 +113,8 @@ func (p *oauthPayload) domain() string { } // New returns an [Account] that can be used to fetch a [vehicle.Vehicle]. -func New(oauthToken string) (*Account, error) { +// Optional userAgent can be passed in - otherwise it will be generated from code +func New(oauthToken, userAgent string) (*Account, error) { parts := strings.Split(oauthToken, ".") if len(parts) != 3 { return nil, fmt.Errorf("client provided malformed OAuth token") @@ -133,7 +133,7 @@ func New(oauthToken string) (*Account, error) { return nil, fmt.Errorf("client provided OAuth token with invalid audiences") } return &Account{ - UserAgent: UserAgent, + UserAgent: buildUserAgent(userAgent), authHeader: "Bearer " + strings.TrimSpace(oauthToken), Host: domain, }, nil diff --git a/pkg/account/account_test.go b/pkg/account/account_test.go index a0dd948..16fd9ce 100644 --- a/pkg/account/account_test.go +++ b/pkg/account/account_test.go @@ -13,29 +13,29 @@ func b64Encode(payload string) string { func TestNewAccount(t *testing.T) { validDomain := "fleet-api.example.tesla.com" - if _, err := New(""); err == nil { + if _, err := New("", ""); err == nil { t.Error("Returned success empty JWT") } - if _, err := New(b64Encode(validDomain)); err == nil { + if _, err := New(b64Encode(validDomain), ""); err == nil { t.Error("Returned success on one-field JWT") } - if _, err := New("x." + b64Encode(validDomain)); err == nil { + if _, err := New("x."+b64Encode(validDomain), ""); err == nil { t.Error("Returned success on two-field JWT") } - if _, err := New("x." + b64Encode(validDomain) + "y.z"); err == nil { + if _, err := New("x."+b64Encode(validDomain)+"y.z", ""); err == nil { t.Error("Returned success on four-field JWT") } - if _, err := New("x." + validDomain + ".y"); err == nil { + if _, err := New("x."+validDomain+".y", ""); err == nil { t.Error("Returned success on non-base64 encoded JWT") } - if _, err := New("x." + b64Encode("{\"aud\": \"example.com\"}") + ".y"); err == nil { + if _, err := New("x."+b64Encode("{\"aud\": \"example.com\"}")+".y", ""); err == nil { t.Error("Returned success on untrusted domain") } - if _, err := New("x." + b64Encode(fmt.Sprintf("{\"aud\": \"%s\"}", validDomain)) + ".y"); err == nil { + if _, err := New("x."+b64Encode(fmt.Sprintf("{\"aud\": \"%s\"}", validDomain))+".y", ""); err == nil { t.Error("Returned when aud field not a list") } - acct, err := New("x." + b64Encode(fmt.Sprintf("{\"aud\": [\"%s\"]}", validDomain)) + ".y") + acct, err := New("x."+b64Encode(fmt.Sprintf("{\"aud\": [\"%s\"]}", validDomain))+".y", "") if err != nil { t.Fatalf("Returned error on valid JWT: %s", err) } @@ -49,7 +49,7 @@ func TestDomainDefault(t *testing.T) { Audiences: []string{"https://auth.tesla.com/nts"}, } - acct, err := New(makeTestJWT(payload)) + acct, err := New(makeTestJWT(payload), "") if err != nil { t.Fatalf("Returned error on valid JWT: %s", err) } @@ -64,7 +64,7 @@ func TestDomainExtraction(t *testing.T) { OUCode: "EU", } - acct, err := New(makeTestJWT(payload)) + acct, err := New(makeTestJWT(payload), "") if err != nil { t.Fatalf("Returned error on valid JWT: %s", err) } diff --git a/pkg/cli/config.go b/pkg/cli/config.go index 4ff59fa..e1a1439 100644 --- a/pkg/cli/config.go +++ b/pkg/cli/config.go @@ -421,7 +421,7 @@ func (c *Config) Account() (*account.Account, error) { if err != nil { return nil, err } - return account.New(token) + return account.New(token, "") } // SavePrivateKey writes skey to the system keyring or file, depending on what options are diff --git a/pkg/connector/inet/inet.go b/pkg/connector/inet/inet.go index 2e27510..a60f26b 100644 --- a/pkg/connector/inet/inet.go +++ b/pkg/connector/inet/inet.go @@ -119,10 +119,8 @@ func SendFleetAPICommand(ctx context.Context, client *http.Client, userAgent, au switch result.StatusCode { case http.StatusOK: return body, nil - case http.StatusUnprocessableEntity: - if bytes.Contains(body, []byte("vehicle does not support signed commands")) { - return nil, protocol.ErrProtocolNotSupported - } + case http.StatusUnprocessableEntity: // HTTP: 422 on commands endpoint means protocol is not supported (fallback to regular commands) + return nil, protocol.ErrProtocolNotSupported case http.StatusServiceUnavailable: return nil, ErrVehicleNotAwake case http.StatusRequestTimeout: diff --git a/pkg/proxy/command.go b/pkg/proxy/command.go new file mode 100644 index 0000000..f9f56c9 --- /dev/null +++ b/pkg/proxy/command.go @@ -0,0 +1,489 @@ +package proxy + +import ( + "context" + "errors" + "fmt" + "net/http" + "time" + + "github.com/teslamotors/vehicle-command/pkg/connector/inet" + "github.com/teslamotors/vehicle-command/pkg/protocol" + "github.com/teslamotors/vehicle-command/pkg/vehicle" + + carserver "github.com/teslamotors/vehicle-command/pkg/protocol/protobuf/carserver" +) + +var ( + // ErrCommandNotImplemented indicates a command has not be implemented in the SDK + ErrCommandNotImplemented = errors.New("command not implemented") + + // ErrCommandUseRESTAPI indicates vehicle/command is not supported by the protocol + ErrCommandUseRESTAPI = errors.New("command requires using the REST API") + + seatPositions = []vehicle.SeatPosition{ + vehicle.SeatFrontLeft, + vehicle.SeatFrontRight, + vehicle.SeatSecondRowLeft, + vehicle.SeatSecondRowLeftBack, + vehicle.SeatSecondRowCenter, + vehicle.SeatSecondRowRight, + vehicle.SeatSecondRowRightBack, + vehicle.SeatThirdRowLeft, + vehicle.SeatThirdRowRight, + } +) + +// RequestParameters allows simple type check +type RequestParameters map[string]interface{} + +// ExtractCommandAction use command to define which action should be executed. +func ExtractCommandAction(ctx context.Context, command string, params RequestParameters) (func(*vehicle.Vehicle) error, error) { + switch command { + // Media controls + case "adjust_volume": + volume, err := params.getNumber("volume", true) + if err != nil { + return nil, err + } + return func(v *vehicle.Vehicle) error { return v.SetVolume(ctx, float32(volume)) }, nil + case "remote_boombox": + return nil, ErrCommandNotImplemented + // Climate Controls + case "auto_conditioning_start": + return func(v *vehicle.Vehicle) error { return v.ClimateOn(ctx) }, nil + case "auto_conditioning_stop": + return func(v *vehicle.Vehicle) error { return v.ClimateOff(ctx) }, nil + case "charge_max_range": + return func(v *vehicle.Vehicle) error { return v.ChargeMaxRange(ctx) }, nil + case "remote_seat_cooler_request": + level, seat, err := params.settingForCoolerSeatPosition() + if err != nil { + return nil, err + } + return func(v *vehicle.Vehicle) error { return v.SetSeatCooler(ctx, level, seat) }, nil + case "remote_seat_heater_request": + setting, err := params.settingForHeatSeatPosition() + if err != nil { + return nil, err + } + + return func(v *vehicle.Vehicle) error { return v.SetSeatHeater(ctx, setting) }, nil + case "remote_auto_seat_climate_request": + level, seat, err := params.settingForAutoSeatPosition() + if err != nil { + return nil, err + } + return func(v *vehicle.Vehicle) error { return v.SetSeatCooler(ctx, level, seat) }, nil + case "remote_steering_wheel_heater_request": + on, err := params.getBool("on", true) + if err != nil { + return nil, err + } + return func(v *vehicle.Vehicle) error { return v.SetSteeringWheelHeater(ctx, on) }, nil + case "set_bioweapon_mode": + on, err := params.getBool("on", true) + if err != nil { + return nil, err + } + override, err := params.getBool("manual_override", true) + if err != nil { + return nil, err + } + return func(v *vehicle.Vehicle) error { return v.SetBioweaponDefenseMode(ctx, on, override) }, nil + case "set_cabin_overheat_protection": + on, err := params.getBool("on", true) + if err != nil { + return nil, err + } + fanOnly, err := params.getBool("fan_only", false) + if err != nil { + return nil, err + } + return func(v *vehicle.Vehicle) error { return v.SetCabinOverheatProtection(ctx, on, fanOnly) }, nil + case "set_climate_keeper_mode": + // 0 : off + // 1 : On + // 2 : Dog + // 3 : Camp + mode, err := params.getNumber("climate_keeper_mode", true) + if err != nil { + return nil, err + } + override, err := params.getBool("manual_override", false) + if err != nil { + return nil, err + } + return func(v *vehicle.Vehicle) error { + return v.SetClimateKeeperMode(ctx, vehicle.ClimateKeeperMode(mode), override) + }, nil + case "set_cop_temp": + level, err := params.getNumber("cop_temp", true) + if err != nil { + return nil, err + } + return func(v *vehicle.Vehicle) error { + return v.SetCabinOverheatProtectionTemperature(ctx, vehicle.Level(level)) + }, nil + case "set_preconditioning_max": + on, err := params.getBool("on", true) + if err != nil { + return nil, err + } + override, err := params.getBool("manual_override", false) + if err != nil { + return nil, err + } + return func(v *vehicle.Vehicle) error { return v.SetPreconditioningMax(ctx, on, override) }, nil + case "set_temps": + driverTemp, err := params.getNumber("driver_temp", false) + if err != nil { + return nil, err + } + passengerTemp, err := params.getNumber("passenger_temp", false) + if err != nil { + return nil, err + } + return func(v *vehicle.Vehicle) error { + return v.ChangeClimateTemp(ctx, float32(driverTemp), float32(passengerTemp)) + }, nil + // vehicle.Vehicle actuation commands + case "actuate_trunk": + if which, err := params.getString("which_trunk", false); err == nil { + switch which { + case "front": + return func(v *vehicle.Vehicle) error { return v.OpenFrunk(ctx) }, nil + case "rear": + return func(v *vehicle.Vehicle) error { return v.OpenTrunk(ctx) }, nil + default: + return nil, &protocol.NominalError{Details: protocol.NewError("invalid_value", false, false)} + } + } + return func(v *vehicle.Vehicle) error { return v.OpenTrunk(ctx) }, nil + case "charge_port_door_open": + return func(v *vehicle.Vehicle) error { return v.ChargePortOpen(ctx) }, nil + case "charge_port_door_close": + return func(v *vehicle.Vehicle) error { return v.ChargePortClose(ctx) }, nil + case "flash_lights": + return func(v *vehicle.Vehicle) error { return v.FlashLights(ctx) }, nil + case "honk_horn": + return func(v *vehicle.Vehicle) error { return v.HonkHorn(ctx) }, nil + case "remote_start_drive": + return func(v *vehicle.Vehicle) error { return v.RemoteDrive(ctx) }, nil + // Charging controls + case "charge_standard": + return func(v *vehicle.Vehicle) error { return v.ChargeStandardRange(ctx) }, nil + case "charge_start": + return func(v *vehicle.Vehicle) error { return v.ChargeStart(ctx) }, nil + case "charge_stop": + return func(v *vehicle.Vehicle) error { return v.ChargeStop(ctx) }, nil + case "set_charging_amps": + amps, err := params.getNumber("charging_amps", true) + if err != nil { + return nil, err + } + return func(v *vehicle.Vehicle) error { return v.SetChargingAmps(ctx, int32(amps)) }, nil + case "set_scheduled_charging": + on, err := params.getBool("enable", true) + if err != nil { + return nil, err + } + scheduledTime, err := params.getTimeAfterMidnight("time") + if err != nil { + return nil, err + } + return func(v *vehicle.Vehicle) error { return v.ScheduleCharging(ctx, on, scheduledTime) }, nil + case "set_charge_limit": + limit, err := params.getNumber("percent", true) + if err != nil { + return nil, err + } + return func(v *vehicle.Vehicle) error { return v.ChangeChargeLimit(ctx, int32(limit)) }, nil + case "set_scheduled_departure": + enable, err := params.getBool("enable", true) + if err != nil { + return nil, err + } + if !enable { + return func(v *vehicle.Vehicle) error { return v.ClearScheduledDeparture(ctx) }, nil + } + + offPeakPolicy, err := params.getPolicy("off_peak_charging_enabled", "off_peak_charging_weekdays_only") + if err != nil { + return nil, err + } + preconditionPolicy, err := params.getPolicy("preconditioning_enabled", "preconditioning_weekdays_only") + if err != nil { + return nil, err + } + + departureTime, err := params.getTimeAfterMidnight("departure_time") + if err != nil { + return nil, err + } + endOffPeakTime, err := params.getTimeAfterMidnight("end_off_peak_time") + if err != nil { + return nil, err + } + return func(v *vehicle.Vehicle) error { + return v.ScheduleDeparture(ctx, departureTime, endOffPeakTime, preconditionPolicy, offPeakPolicy) + }, nil + case "set_managed_charge_current_request": + return nil, ErrCommandUseRESTAPI + case "set_managed_charger_location": + return nil, ErrCommandUseRESTAPI + case "set_managed_scheduled_charging_time": + return nil, ErrCommandUseRESTAPI + case "set_pin_to_drive": + on, err := params.getBool("on", true) + if err != nil { + return nil, err + } + password, err := params.getString("password", false) + if err != nil { + return nil, err + } + return func(v *vehicle.Vehicle) error { return v.SetPINToDrive(ctx, on, password) }, nil + case "wake_up": + return func(v *vehicle.Vehicle) error { return v.Wakeup(ctx) }, nil + // Security + case "door_lock": + return func(v *vehicle.Vehicle) error { return v.Lock(ctx) }, nil + case "door_unlock": + return func(v *vehicle.Vehicle) error { return v.Unlock(ctx) }, nil + case "reset_pin_to_drive_pin": + return func(v *vehicle.Vehicle) error { return v.ResetPIN(ctx) }, nil + case "reset_valet_pin": + return func(v *vehicle.Vehicle) error { return v.ResetValetPin(ctx) }, nil + case "guest_mode": + on, err := params.getBool("enable", true) + if err != nil { + return nil, err + } + return func(v *vehicle.Vehicle) error { return v.SetGuestMode(ctx, on) }, nil + case "set_sentry_mode": + on, err := params.getBool("on", true) + if err != nil { + return nil, err + } + return func(v *vehicle.Vehicle) error { return v.SetSentryMode(ctx, on) }, nil + case "set_valet_mode": + on, err := params.getBool("on", true) + if err != nil { + return nil, err + } + password, err := params.getString("password", false) + if err != nil { + return nil, err + } + return func(v *vehicle.Vehicle) error { return v.SetValetMode(ctx, on, password) }, nil + case "set_vehicle_name": + name, err := params.getString("vehicle_name", true) + if err != nil { + return nil, err + } + return func(v *vehicle.Vehicle) error { return v.SetVehicleName(ctx, name) }, nil + case "speed_limit_activate": + pin, err := params.getString("pin", true) + if err != nil { + return nil, err + } + return func(v *vehicle.Vehicle) error { return v.ActivateSpeedLimit(ctx, pin) }, nil + case "speed_limit_deactivate": + pin, err := params.getString("pin", true) + if err != nil { + return nil, err + } + return func(v *vehicle.Vehicle) error { return v.DeactivateSpeedLimit(ctx, pin) }, nil + case "speed_limit_clear_pin": + pin, err := params.getString("pin", true) + if err != nil { + return nil, err + } + return func(v *vehicle.Vehicle) error { return v.ClearSpeedLimitPIN(ctx, pin) }, nil + case "speed_limit_set_limit": + speedMPH, err := params.getNumber("limit_mph", true) + if err != nil { + return nil, err + } + return func(v *vehicle.Vehicle) error { return v.SpeedLimitSetLimitMPH(ctx, speedMPH) }, nil + case "trigger_homelink": + lat, err := params.getNumber("lat", true) + if err != nil { + return nil, err + } + lon, err := params.getNumber("lon", true) + if err != nil { + return nil, err + } + return func(v *vehicle.Vehicle) error { return v.TriggerHomelink(ctx, float32(lat), float32(lon)) }, nil + // Updates + case "schedule_software_update": + offsetSeconds, err := params.getNumber("offset_sec", true) + if err != nil { + return nil, err + } + return func(v *vehicle.Vehicle) error { + return v.ScheduleSoftwareUpdate(ctx, time.Duration(offsetSeconds)*time.Second) + }, nil + case "cancel_software_update": + return func(v *vehicle.Vehicle) error { return v.CancelSoftwareUpdate(ctx) }, nil + // Sharing options. These endpoints often require server-side processing, which prevents strict + // end-to-end authentication. + case "navigation_request": + return nil, ErrCommandUseRESTAPI + default: + return nil, &inet.HttpError{Code: http.StatusBadRequest, Message: "{\"response\":null,\"error\":\"invalid_command\",\"error_description\":\"\"}"} + } +} + +func (p RequestParameters) getString(key string, required bool) (string, error) { + value, exists := p[key] + + if exists { + if strValue, isString := value.(string); isString { + return strValue, nil + } + return "", invalidParamError(key) + } + + if !required { + return "", nil + } + + return "", missingParamError(key) +} + +func (p RequestParameters) getBool(key string, required bool) (bool, error) { + value, exists := p[key] + if exists { + if val, isBool := value.(bool); isBool { + return val, nil + } + return false, invalidParamError(key) + } + + if !required { + return false, nil + } + + return false, missingParamError(key) +} + +func (p RequestParameters) getNumber(key string, required bool) (float64, error) { + value, exists := p[key] + if exists { + if num, isFloat64 := value.(float64); isFloat64 { + return num, nil + } + return 0, invalidParamError(key) + } + + if !required { + return 0, nil + } + + return 0, missingParamError(key) +} + +func (p RequestParameters) getPolicy(enabledKey string, weekdaysOnlyKey string) (vehicle.ChargingPolicy, error) { + enabled, err := p.getBool(enabledKey, false) + if err != nil { + return 0, err + } + weekdaysOnly, err := p.getBool(weekdaysOnlyKey, false) + if err != nil { + return 0, err + } + if weekdaysOnly { + return vehicle.ChargingPolicyWeekdays, nil + } + if enabled { + return vehicle.ChargingPolicyAllDays, nil + } + return vehicle.ChargingPolicyOff, nil +} + +func (p RequestParameters) getTimeAfterMidnight(key string) (time.Duration, error) { + minutes, err := p.getNumber(key, false) + if err != nil { + return 0, err + } + // Leave further validation to the car for consistency with previous API. + return time.Duration(minutes) * time.Minute, nil +} + +func (p RequestParameters) settingForHeatSeatPosition() (map[vehicle.SeatPosition]vehicle.Level, error) { + index, err := p.getNumber("seat_position", true) + if err != nil { + return nil, err + } + if int(index) < 0 || int(index) >= len(seatPositions) { + return nil, errors.New("invalid seat position") + } + + level, err := p.getNumber("level", true) + if err != nil { + return nil, err + } + + return map[vehicle.SeatPosition]vehicle.Level{seatPositions[int(index)]: vehicle.Level(level)}, nil +} + +// Note: The API uses 0-3 +func (p RequestParameters) settingForCoolerSeatPosition() (vehicle.Level, vehicle.SeatPosition, error) { + position, err := p.getNumber("seat_position", true) + if err != nil { + return 0, 0, err + } + + var seat vehicle.SeatPosition + switch carserver.HvacSeatCoolerActions_HvacSeatCoolerPosition_E(position) { + case carserver.HvacSeatCoolerActions_HvacSeatCoolerPosition_FrontLeft: + seat = vehicle.SeatFrontLeft + case carserver.HvacSeatCoolerActions_HvacSeatCoolerPosition_FrontRight: + seat = vehicle.SeatFrontRight + default: + seat = vehicle.SeatUnknown + } + + level, err := p.getNumber("seat_cooler_level", true) + if err != nil { + return 0, 0, err + } + + return vehicle.Level(level - 1), seat, nil +} + +// Note: The API uses 0-3 +func (p RequestParameters) settingForAutoSeatPosition() (vehicle.Level, vehicle.SeatPosition, error) { + position, err := p.getNumber("auto_seat_position", true) + if err != nil { + return 0, 0, err + } + + var seat vehicle.SeatPosition + switch carserver.AutoSeatClimateAction_AutoSeatPosition_E(position) { + case carserver.AutoSeatClimateAction_AutoSeatPosition_FrontLeft: + seat = vehicle.SeatFrontLeft + case carserver.AutoSeatClimateAction_AutoSeatPosition_FrontRight: + seat = vehicle.SeatFrontRight + default: + seat = vehicle.SeatUnknown + } + + level, err := p.getNumber("seat_position", true) + if err != nil { + return 0, 0, err + } + + return vehicle.Level(level - 1), seat, nil +} + +func missingParamError(key string) error { + return &protocol.NominalError{Details: fmt.Errorf("missing %s param", key)} +} + +func invalidParamError(key string) error { + return &protocol.NominalError{Details: fmt.Errorf("invalid %s param", key)} +} diff --git a/pkg/proxy/command_test.go b/pkg/proxy/command_test.go new file mode 100644 index 0000000..ccb3baf --- /dev/null +++ b/pkg/proxy/command_test.go @@ -0,0 +1,50 @@ +package proxy_test + +import ( + "context" + "errors" + "fmt" + "net/http" + "testing" + + "github.com/teslamotors/vehicle-command/pkg/connector/inet" + "github.com/teslamotors/vehicle-command/pkg/protocol" + "github.com/teslamotors/vehicle-command/pkg/proxy" + "github.com/teslamotors/vehicle-command/pkg/vehicle" +) + +func TestExtractCommandAction(t *testing.T) { + ctx := context.Background() + params := proxy.RequestParameters{ + "volume": 5.0, + "on": true, + "seat_position": 0, + "level": 2.0, + // Add more test cases for different commands and parameters + } + + tests := []struct { + command string + params proxy.RequestParameters + expectedFunc func(*vehicle.Vehicle) error + expected error + }{ + {"adjust_volume", params, func(v *vehicle.Vehicle) error { return v.SetVolume(ctx, 0.0) }, nil}, + {"adjust_volume", nil, nil, &protocol.NominalError{Details: fmt.Errorf("missing volume param")}}, + {"remote_boombox", params, nil, proxy.ErrCommandNotImplemented}, + {"invalid_command", params, nil, &inet.HttpError{Code: http.StatusBadRequest, Message: "{\"response\":null,\"error\":\"invalid_command\",\"error_description\":\"\"}"}}, + } + + for _, test := range tests { + action, err := proxy.ExtractCommandAction(ctx, test.command, test.params) + + if errors.Is(err, test.expected) { + if test.expected != nil && action != nil { + + t.Errorf("Expected error %#v but got action %p for command %#v", test.expected, action, test.command) + } + } else if err != nil && err.Error() != test.expected.Error() { + t.Errorf("Unexpected error for command %s: %v", test.command, err) + } + } +} diff --git a/pkg/proxy/execute.go b/pkg/proxy/execute.go deleted file mode 100644 index 8e466d7..0000000 --- a/pkg/proxy/execute.go +++ /dev/null @@ -1,451 +0,0 @@ -package proxy - -import ( - "context" - "encoding/json" - "errors" - "fmt" - "io" - "net/http" - "time" - - "github.com/teslamotors/vehicle-command/pkg/connector/inet" - "github.com/teslamotors/vehicle-command/pkg/protocol" - "github.com/teslamotors/vehicle-command/pkg/protocol/protobuf/carserver" - "github.com/teslamotors/vehicle-command/pkg/vehicle" -) - -var ( - ErrNotImplemented = errors.New("command not implemented") - ErrUseRESTAPI = errors.New("command requires using the REST API") -) - -func missingParamError(key string) error { - return &protocol.NominalError{Details: fmt.Errorf("missing %s param", key)} -} - -func invalidParamError(key string) error { - return &protocol.NominalError{Details: fmt.Errorf("invalid %s param", key)} -} - -type requestParameters map[string]interface{} - -func (p requestParameters) getString(key string, required bool) (string, error) { - if value, ok := p[key]; ok { - if s, ok := value.(string); ok { - return s, nil - } else { - return "", invalidParamError(key) - } - } else if !required { - return "", nil - } - return "", missingParamError(key) -} - -func (p requestParameters) getBool(key string, required bool) (bool, error) { - if value, ok := p[key]; ok { - if s, ok := value.(bool); ok { - return s, nil - } else { - return false, invalidParamError(key) - } - } else if !required { - return false, nil - } - return false, missingParamError(key) -} - -func (p requestParameters) getNumber(key string, required bool) (float64, error) { - if value, ok := p[key]; ok { - if s, ok := value.(float64); ok { - return s, nil - } else { - return 0, invalidParamError(key) - } - } else if !required { - return 0, nil - } - return 0, missingParamError(key) -} - -func (p requestParameters) getPolicy(enabledKey string, weekdaysOnlyKey string) (vehicle.ChargingPolicy, error) { - enabled, err := p.getBool(enabledKey, false) - if err != nil { - return 0, err - } - weekdaysOnly, err := p.getBool(weekdaysOnlyKey, false) - if err != nil { - return 0, err - } - if weekdaysOnly { - return vehicle.ChargingPolicyWeekdays, nil - } - if enabled { - return vehicle.ChargingPolicyAllDays, nil - } - return vehicle.ChargingPolicyOff, nil -} - -func (p requestParameters) getTimeAfterMidnight(key string) (time.Duration, error) { - minutes, err := p.getNumber(key, false) - if err != nil { - return 0, err - } - // Leave further validation to the car for consistency with previous API. - return time.Duration(minutes) * time.Minute, nil -} - -func execute(ctx context.Context, req *http.Request, car *vehicle.Vehicle, command string) error { - var params requestParameters - body, err := io.ReadAll(req.Body) - if err != nil { - return err - } - if len(body) > 0 { - if err := json.Unmarshal(body, ¶ms); err != nil { - return &inet.HttpError{Code: http.StatusBadRequest, Message: "invalid JSON: Error occurred while parsing request parameters"} - } - } - switch command { - // Media controls - case "adjust_volume": - volume, err := params.getNumber("volume", true) - if err != nil { - return err - } - return car.SetVolume(ctx, float32(volume)) - case "remote_boombox": - return ErrNotImplemented - // Climate Controls - case "auto_conditioning_start": - return car.ClimateOn(ctx) - case "auto_conditioning_stop": - return car.ClimateOff(ctx) - case "charge_max_range": - return car.ChargeMaxRange(ctx) - case "remote_seat_cooler_request": - position, err := params.getNumber("seat_position", true) - if err != nil { - return err - } - var seat vehicle.SeatPosition - switch carserver.HvacSeatCoolerActions_HvacSeatCoolerPosition_E(position) { - case carserver.HvacSeatCoolerActions_HvacSeatCoolerPosition_FrontLeft: - seat = vehicle.SeatFrontLeft - case carserver.HvacSeatCoolerActions_HvacSeatCoolerPosition_FrontRight: - seat = vehicle.SeatFrontRight - default: - seat = vehicle.SeatUnknown - } - level, err := params.getNumber("seat_cooler_level", true) - if err != nil { - return err - } - // Our API uses 0-3 - return car.SetSeatCooler(ctx, vehicle.Level(level-1), seat) - case "remote_seat_heater_request": - positions := []vehicle.SeatPosition{ - vehicle.SeatFrontLeft, - vehicle.SeatFrontRight, - vehicle.SeatSecondRowLeft, - vehicle.SeatSecondRowLeftBack, - vehicle.SeatSecondRowCenter, - vehicle.SeatSecondRowRight, - vehicle.SeatSecondRowRightBack, - vehicle.SeatThirdRowLeft, - vehicle.SeatThirdRowRight, - } - index, err := params.getNumber("seat_position", true) - if err != nil { - return err - } - if int(index) < 0 || int(index) >= len(positions) { - return errors.New("invalid seat position") - } - - level, err := params.getNumber("level", true) - if err != nil { - return err - } - - setting := map[vehicle.SeatPosition]vehicle.Level{ - positions[int(index)]: vehicle.Level(level), - } - - return car.SetSeatHeater(ctx, setting) - case "remote_auto_seat_climate_request": - position, err := params.getNumber("auto_seat_position", true) - if err != nil { - return err - } - - var seat vehicle.SeatPosition - switch carserver.AutoSeatClimateAction_AutoSeatPosition_E(position) { - case carserver.AutoSeatClimateAction_AutoSeatPosition_FrontLeft: - seat = vehicle.SeatFrontLeft - case carserver.AutoSeatClimateAction_AutoSeatPosition_FrontRight: - seat = vehicle.SeatFrontRight - default: - seat = vehicle.SeatUnknown - } - - level, err := params.getNumber("seat_position", true) - if err != nil { - return err - } - - return car.SetSeatCooler(ctx, vehicle.Level(level-1), seat) - case "remote_steering_wheel_heater_request": - on, err := params.getBool("on", true) - if err != nil { - return err - } - return car.SetSteeringWheelHeater(ctx, on) - case "set_bioweapon_mode": - on, err := params.getBool("on", true) - if err != nil { - return err - } - override, err := params.getBool("manual_override", true) - if err != nil { - return err - } - return car.SetBioweaponDefenseMode(ctx, on, override) - case "set_cabin_overheat_protection": - on, err := params.getBool("on", true) - if err != nil { - return err - } - fanOnly, err := params.getBool("fan_only", false) - if err != nil { - return err - } - return car.SetCabinOverheatProtection(ctx, on, fanOnly) - case "set_climate_keeper_mode": - // 0 : off - // 1 : On - // 2 : Dog - // 3 : Camp - mode, err := params.getNumber("climate_keeper_mode", true) - if err != nil { - return err - } - override, err := params.getBool("manual_override", false) - if err != nil { - return err - } - return car.SetClimateKeeperMode(ctx, vehicle.ClimateKeeperMode(mode), override) - case "set_cop_temp": - level, err := params.getNumber("cop_temp", true) - if err != nil { - return nil - } - return car.SetCabinOverheatProtectionTemperature(ctx, vehicle.Level(level)) - case "set_preconditioning_max": - on, err := params.getBool("on", true) - if err != nil { - return err - } - override, err := params.getBool("manual_override", false) - if err != nil { - return err - } - return car.SetPreconditioningMax(ctx, on, override) - case "set_temps": - driverTemp, err := params.getNumber("driver_temp", false) - if err != nil { - return err - } - passengerTemp, err := params.getNumber("passenger_temp", false) - if err != nil { - return err - } - return car.ChangeClimateTemp(ctx, float32(driverTemp), float32(passengerTemp)) - // Vehicle actuation commands - case "actuate_trunk": - if which, err := params.getString("which_trunk", false); err == nil { - switch which { - case "front": - return car.OpenFrunk(ctx) - case "rear": - return car.OpenTrunk(ctx) - default: - return &protocol.NominalError{ - Details: protocol.NewError("invalid_value", false, false), - } - } - } - return car.OpenTrunk(ctx) - case "charge_port_door_open": - return car.ChargePortOpen(ctx) - case "charge_port_door_close": - return car.ChargePortClose(ctx) - case "flash_lights": - return car.FlashLights(ctx) - case "honk_horn": - return car.HonkHorn(ctx) - case "remote_start_drive": - return car.RemoteDrive(ctx) - // Charging controls - case "charge_standard": - return car.ChargeStandardRange(ctx) - case "charge_start": - return car.ChargeStart(ctx) - case "charge_stop": - return car.ChargeStop(ctx) - case "set_charging_amps": - amps, err := params.getNumber("charging_amps", true) - if err != nil { - return err - } - return car.SetChargingAmps(ctx, int32(amps)) - case "set_scheduled_charging": - on, err := params.getBool("enable", true) - if err != nil { - return err - } - scheduledTime, err := params.getTimeAfterMidnight("time") - if err != nil { - return err - } - return car.ScheduleCharging(ctx, on, scheduledTime) - case "set_charge_limit": - limit, err := params.getNumber("percent", true) - if err != nil { - return err - } - return car.ChangeChargeLimit(ctx, int32(limit)) - case "set_scheduled_departure": - enable, err := params.getBool("enable", true) - if err != nil { - return err - } - if !enable { - return car.ClearScheduledDeparture(ctx) - } - - offPeakPolicy, err := params.getPolicy("off_peak_charging_enabled", "off_peak_charging_weekdays_only") - if err != nil { - return err - } - preconditionPolicy, err := params.getPolicy("preconditioning_enabled", "preconditioning_weekdays_only") - if err != nil { - return nil - } - - departureTime, err := params.getTimeAfterMidnight("departure_time") - if err != nil { - return err - } - endOffPeakTime, err := params.getTimeAfterMidnight("end_off_peak_time") - if err != nil { - return err - } - return car.ScheduleDeparture(ctx, departureTime, endOffPeakTime, preconditionPolicy, offPeakPolicy) - case "set_managed_charge_current_request": - return ErrUseRESTAPI - case "set_managed_charger_location": - return ErrUseRESTAPI - case "set_managed_scheduled_charging_time": - return ErrUseRESTAPI - case "set_pin_to_drive": - on, err := params.getBool("on", true) - if err != nil { - return err - } - password, err := params.getString("password", false) - if err != nil { - return err - } - return car.SetPINToDrive(ctx, on, password) - case "wake_up": - return car.Wakeup(ctx) - // Security - case "door_lock": - return car.Lock(ctx) - case "door_unlock": - return car.Unlock(ctx) - case "reset_pin_to_drive_pin": - return car.ResetPIN(ctx) - case "reset_valet_pin": - return car.ResetValetPin(ctx) - case "guest_mode": - on, err := params.getBool("enable", true) - if err != nil { - return err - } - return car.SetGuestMode(ctx, on) - case "set_sentry_mode": - on, err := params.getBool("on", true) - if err != nil { - return err - } - return car.SetSentryMode(ctx, on) - case "set_valet_mode": - on, err := params.getBool("on", true) - if err != nil { - return err - } - password, err := params.getString("password", false) - if err != nil { - return err - } - return car.SetValetMode(ctx, on, password) - case "set_vehicle_name": - name, err := params.getString("vehicle_name", true) - if err != nil { - return err - } - return car.SetVehicleName(ctx, name) - case "speed_limit_activate": - pin, err := params.getString("pin", true) - if err != nil { - return err - } - return car.ActivateSpeedLimit(ctx, pin) - case "speed_limit_deactivate": - pin, err := params.getString("pin", true) - if err != nil { - return err - } - return car.DeactivateSpeedLimit(ctx, pin) - case "speed_limit_clear_pin": - pin, err := params.getString("pin", true) - if err != nil { - return err - } - return car.ClearSpeedLimitPIN(ctx, pin) - case "speed_limit_set_limit": - speedMPH, err := params.getNumber("limit_mph", true) - if err != nil { - return err - } - return car.SpeedLimitSetLimitMPH(ctx, speedMPH) - case "trigger_homelink": - lat, err := params.getNumber("lat", true) - if err != nil { - return err - } - lon, err := params.getNumber("lon", true) - if err != nil { - return err - } - return car.TriggerHomelink(ctx, float32(lat), float32(lon)) - // Updates - case "schedule_software_update": - offsetSeconds, err := params.getNumber("offset_sec", true) - if err != nil { - return err - } - return car.ScheduleSoftwareUpdate(ctx, time.Duration(offsetSeconds)*time.Second) - case "cancel_software_update": - return car.CancelSoftwareUpdate(ctx) - // Sharing options. These endpoints often require server-side processing, which prevents strict - // end-to-end authentication. - case "navigation_request": - return ErrUseRESTAPI - default: - return &inet.HttpError{Code: http.StatusBadRequest, Message: "{\"response\":null,\"error\":\"invalid_command\",\"error_description\":\"\"}"} - } -} diff --git a/pkg/proxy/proxy.go b/pkg/proxy/proxy.go index 79964f8..e33244e 100644 --- a/pkg/proxy/proxy.go +++ b/pkg/proxy/proxy.go @@ -18,12 +18,14 @@ import ( "github.com/teslamotors/vehicle-command/pkg/cache" "github.com/teslamotors/vehicle-command/pkg/connector/inet" "github.com/teslamotors/vehicle-command/pkg/protocol" + "github.com/teslamotors/vehicle-command/pkg/vehicle" ) const ( - defaultTimeout = 10 * time.Second - maxRequestBodyBytes = 512 - vinLength = 17 + defaultTimeout = 10 * time.Second + maxRequestBodyBytes = 512 + vinLength = 17 + proxyProtocolVersion = "tesla-http-proxy/1.0.0" ) func getAccount(req *http.Request) (*account.Account, error) { @@ -31,7 +33,7 @@ func getAccount(req *http.Request) (*account.Account, error) { if !ok { return nil, fmt.Errorf("client did not provide an OAuth token") } - return account.New(token) + return account.New(token, proxyProtocolVersion) } // Proxy exposes an HTTP API for sending vehicle commands. @@ -228,7 +230,9 @@ func (p *Proxy) ServeHTTP(w http.ResponseWriter, req *http.Request) { if p.isNotSupported(vin) { p.forwardRequest(acct.Host, w, req) } else { - p.handleVehicleCommand(acct, w, req, command, vin) + if err := p.handleVehicleCommand(acct, w, req, command, vin); err == ErrCommandUseRESTAPI { + p.forwardRequest(acct.Host, w, req) + } } return } @@ -236,7 +240,7 @@ func (p *Proxy) ServeHTTP(w http.ResponseWriter, req *http.Request) { p.forwardRequest(acct.Host, w, req) } -func (p *Proxy) handleVehicleCommand(acct *account.Account, w http.ResponseWriter, req *http.Request, command string, vin string) { +func (p *Proxy) handleVehicleCommand(acct *account.Account, w http.ResponseWriter, req *http.Request, command, vin string) error { ctx, cancel := context.WithTimeout(context.Background(), p.Timeout) defer cancel() @@ -244,51 +248,82 @@ func (p *Proxy) handleVehicleCommand(acct *account.Account, w http.ResponseWrite // the vehicle.Vehicle object. VCSEC commands fail if they arrive out of order, anyway. if err := p.lockVIN(ctx, vin); err != nil { writeJSONError(w, http.StatusServiceUnavailable, err) - return + return err } defer p.unlockVIN(vin) - log.Debug("Executing %s on %s", command, vin) - if req.Method != http.MethodPost { - writeJSONError(w, http.StatusMethodNotAllowed, nil) - return - } - car, err := acct.GetVehicle(ctx, vin, p.commandKey, p.sessions) - if err != nil || car == nil { - writeJSONError(w, http.StatusInternalServerError, err) - return + car, commandToExecuteFunc, err := p.loadVehicleAndCommandFromRequest(ctx, acct, w, req, command, vin) + if err != nil { + return err } if err := car.Connect(ctx); err != nil { writeJSONError(w, http.StatusInternalServerError, err) - return + return err } defer car.Disconnect() if err := car.StartSession(ctx, nil); err == protocol.ErrProtocolNotSupported { p.markUnsupportedVIN(vin) p.forwardRequest(acct.Host, w, req) - return + return err } else if err != nil { writeJSONError(w, http.StatusInternalServerError, err) - return + return err } defer car.UpdateCachedSessions(p.sessions) - err = execute(ctx, req, car, command) - if err == ErrUseRESTAPI { - p.forwardRequest(acct.Host, w, req) - return + if err = commandToExecuteFunc(car); err == ErrCommandUseRESTAPI { + return err } if protocol.IsNominalError(err) { writeJSONError(w, http.StatusOK, err) - return + return err } if err != nil { writeJSONError(w, http.StatusInternalServerError, err) - return + return err } w.Header().Add("Content-Type", "application/json") fmt.Fprintln(w, "{\"response\":{\"result\":true,\"reason\":\"\"}}") + return nil +} + +func (p *Proxy) loadVehicleAndCommandFromRequest(ctx context.Context, acct *account.Account, w http.ResponseWriter, req *http.Request, + command, vin string) (*vehicle.Vehicle, func(*vehicle.Vehicle) error, error) { + + log.Debug("Executing %s on %s", command, vin) + if req.Method != http.MethodPost { + writeJSONError(w, http.StatusMethodNotAllowed, nil) + return nil, nil, fmt.Errorf("Wrong http method") + } + + commandToExecuteFunc, err := extractCommandAction(ctx, req, command) + if err != nil { + return nil, nil, err + } + + car, err := acct.GetVehicle(ctx, vin, p.commandKey, p.sessions) + if err != nil || car == nil { + writeJSONError(w, http.StatusInternalServerError, err) + return nil, nil, err + } + + return car, commandToExecuteFunc, err +} + +func extractCommandAction(ctx context.Context, req *http.Request, command string) (func(*vehicle.Vehicle) error, error) { + var params RequestParameters + body, err := io.ReadAll(req.Body) + if err != nil { + return nil, err + } + if len(body) > 0 { + if err := json.Unmarshal(body, ¶ms); err != nil { + return nil, &inet.HttpError{Code: http.StatusBadRequest, Message: "invalid JSON: Error occurred while parsing request parameters"} + } + } + + return ExtractCommandAction(ctx, command, params) } diff --git a/pkg/vehicle/vehicle_mock_test.go b/pkg/vehicle/vehicle_mock_test.go new file mode 100644 index 0000000..4594009 --- /dev/null +++ b/pkg/vehicle/vehicle_mock_test.go @@ -0,0 +1,9 @@ +package vehicle_test + +import "context" + +type MockVehicle struct{} + +func (v *MockVehicle) SetVolume(ctx context.Context, volume float32) error { + return nil +}