diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 674f424..47b3d27 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -22,5 +22,11 @@ jobs: make format git diff --exit-code + - name: Mocks up to date + run: | + go install go.uber.org/mock/mockgen@latest + make generate-mocks + git diff --exit-code + - name: Test run: make test diff --git a/Makefile b/Makefile index 557f89c..7bbc0b4 100644 --- a/Makefile +++ b/Makefile @@ -19,4 +19,7 @@ install: test doc-images: docker run -v ./:/data plantuml/plantuml "doc" -.PHONY: install build test format set-version doc-images +generate-mocks: + mockgen -source=pkg/proxy/proxy.go -destination mocks/proxy.go -package=mocks -mock_names Vehicle=ProxyVehicle,Account=ProxyAccount + +.PHONY: install build test format set-version doc-images generate-mocks diff --git a/cmd/tesla-control/commands.go b/cmd/tesla-control/commands.go index 345ca38..cc0fa91 100644 --- a/cmd/tesla-control/commands.go +++ b/cmd/tesla-control/commands.go @@ -11,11 +11,14 @@ import ( "time" "github.com/teslamotors/vehicle-command/pkg/account" + "github.com/teslamotors/vehicle-command/pkg/action" "github.com/teslamotors/vehicle-command/pkg/cli" "github.com/teslamotors/vehicle-command/pkg/protocol" + "github.com/teslamotors/vehicle-command/pkg/protocol/protobuf/carserver" "github.com/teslamotors/vehicle-command/pkg/protocol/protobuf/keys" "github.com/teslamotors/vehicle-command/pkg/protocol/protobuf/vcsec" "github.com/teslamotors/vehicle-command/pkg/vehicle" + "google.golang.org/protobuf/encoding/protojson" ) @@ -47,7 +50,7 @@ type Argument struct { help string } -type Handler func(ctx context.Context, acct *account.Account, car *vehicle.Vehicle, args map[string]string) error +type Handler func(ctx context.Context, acct *account.Account, vehicle *vehicle.Vehicle, args map[string]string) error type Command struct { help string @@ -175,12 +178,12 @@ func checkReadiness(commandName string, havePrivateKey, haveOAuth, haveVIN bool) return info, nil } -func execute(ctx context.Context, acct *account.Account, car *vehicle.Vehicle, args []string) error { +func execute(ctx context.Context, acct *account.Account, vehicle *vehicle.Vehicle, args []string) error { if len(args) == 0 { return errors.New("missing COMMAND") } - info, err := checkReadiness(args[0], car != nil && car.PrivateKeyAvailable(), acct != nil, car != nil) + info, err := checkReadiness(args[0], vehicle != nil && vehicle.PrivateKeyAvailable(), acct != nil, vehicle != nil) if err != nil { return err } @@ -201,7 +204,7 @@ func execute(ctx context.Context, acct *account.Account, car *vehicle.Vehicle, a keywords[argInfo.name] = args[index] index++ } - err = info.handler(ctx, acct, car, keywords) + err = info.handler(ctx, acct, vehicle, keywords) } // Print command-specific help @@ -247,40 +250,40 @@ var commands = map[string]*Command{ help: "Unlock vehicle", requiresAuth: true, requiresFleetAPI: false, - handler: func(ctx context.Context, acct *account.Account, car *vehicle.Vehicle, args map[string]string) error { - return car.Unlock(ctx) + handler: func(ctx context.Context, acct *account.Account, vehicle *vehicle.Vehicle, args map[string]string) error { + return vehicle.ExecuteAction(ctx, action.Unlock()) }, }, "lock": &Command{ help: "Lock vehicle", requiresAuth: true, requiresFleetAPI: false, - handler: func(ctx context.Context, acct *account.Account, car *vehicle.Vehicle, args map[string]string) error { - return car.Lock(ctx) + handler: func(ctx context.Context, acct *account.Account, vehicle *vehicle.Vehicle, args map[string]string) error { + return vehicle.ExecuteAction(ctx, action.Lock()) }, }, "drive": &Command{ help: "Remote start vehicle", requiresAuth: true, requiresFleetAPI: false, - handler: func(ctx context.Context, acct *account.Account, car *vehicle.Vehicle, args map[string]string) error { - return car.RemoteDrive(ctx) + handler: func(ctx context.Context, acct *account.Account, vehicle *vehicle.Vehicle, args map[string]string) error { + return vehicle.ExecuteAction(ctx, action.RemoteDrive()) }, }, "climate-on": &Command{ help: "Turn on climate control", requiresAuth: true, requiresFleetAPI: false, - handler: func(ctx context.Context, acct *account.Account, car *vehicle.Vehicle, args map[string]string) error { - return car.ClimateOn(ctx) + handler: func(ctx context.Context, acct *account.Account, vehicle *vehicle.Vehicle, args map[string]string) error { + return vehicle.ExecuteAction(ctx, action.ClimateOn()) }, }, "climate-off": &Command{ help: "Turn off climate control", requiresAuth: true, requiresFleetAPI: false, - handler: func(ctx context.Context, acct *account.Account, car *vehicle.Vehicle, args map[string]string) error { - return car.ClimateOff(ctx) + handler: func(ctx context.Context, acct *account.Account, vehicle *vehicle.Vehicle, args map[string]string) error { + return vehicle.ExecuteAction(ctx, action.ClimateOff()) }, }, "climate-set-temp": &Command{ @@ -290,7 +293,7 @@ var commands = map[string]*Command{ args: []Argument{ Argument{name: "TEMP", help: "Desired temperature (e.g., 70f or 21c; defaults to Celsius)"}, }, - handler: func(ctx context.Context, acct *account.Account, car *vehicle.Vehicle, args map[string]string) error { + handler: func(ctx context.Context, acct *account.Account, vehicle *vehicle.Vehicle, args map[string]string) error { var degrees float32 var unit string if _, err := fmt.Sscanf(args["TEMP"], "%f%s", °rees, &unit); err != nil { @@ -301,7 +304,7 @@ var commands = map[string]*Command{ } else if unit != "C" && unit != "c" { return fmt.Errorf("temperature units must be C or F") } - return car.ChangeClimateTemp(ctx, degrees, degrees) + return vehicle.ExecuteAction(ctx, action.ChangeClimateTemp(degrees, degrees)) }, }, "add-key": &Command{ @@ -311,9 +314,9 @@ var commands = map[string]*Command{ args: []Argument{ Argument{name: "PUBLIC_KEY", help: "file containing public key (or corresponding private key)"}, Argument{name: "ROLE", help: "One of: owner, driver, fm (fleet manager), vehicle_monitor, charging_manager"}, - Argument{name: "FORM_FACTOR", help: "One of: nfc_card, ios_device, android_device, cloud_key"}, + Argument{name: "FORM_FACTOR", help: "One of: nfc_vehicled, ios_device, android_device, cloud_key"}, }, - handler: func(ctx context.Context, acct *account.Account, car *vehicle.Vehicle, args map[string]string) error { + handler: func(ctx context.Context, acct *account.Account, vehicle *vehicle.Vehicle, args map[string]string) error { role, ok := keys.Role_value["ROLE_"+strings.ToUpper(args["ROLE"])] if !ok { return fmt.Errorf("%w: invalid ROLE", ErrCommandLineArgs) @@ -326,19 +329,19 @@ var commands = map[string]*Command{ if err != nil { return fmt.Errorf("invalid public key: %s", err) } - return car.AddKeyWithRole(ctx, publicKey, keys.Role(role), vcsec.KeyFormFactor(formFactor)) + return vehicle.AddKeyWithRole(ctx, publicKey, keys.Role(role), vcsec.KeyFormFactor(formFactor)) }, }, "add-key-request": &Command{ - help: "Request NFC-card approval for a enrolling PUBLIC_KEY with ROLE and FORM_FACTOR", + help: "Request NFC-vehicled approval for a enrolling PUBLIC_KEY with ROLE and FORM_FACTOR", requiresAuth: false, requiresFleetAPI: false, args: []Argument{ Argument{name: "PUBLIC_KEY", help: "file containing public key (or corresponding private key)"}, Argument{name: "ROLE", help: "One of: owner, driver, fm (fleet manager), vehicle_monitor, charging_manager"}, - Argument{name: "FORM_FACTOR", help: "One of: nfc_card, ios_device, android_device, cloud_key"}, + Argument{name: "FORM_FACTOR", help: "One of: nfc_vehicled, ios_device, android_device, cloud_key"}, }, - handler: func(ctx context.Context, acct *account.Account, car *vehicle.Vehicle, args map[string]string) error { + handler: func(ctx context.Context, acct *account.Account, vehicle *vehicle.Vehicle, args map[string]string) error { role, ok := keys.Role_value["ROLE_"+strings.ToUpper(args["ROLE"])] if !ok { return fmt.Errorf("%w: invalid ROLE", ErrCommandLineArgs) @@ -351,10 +354,10 @@ var commands = map[string]*Command{ if err != nil { return fmt.Errorf("invalid public key: %s", err) } - if err := car.SendAddKeyRequestWithRole(ctx, publicKey, keys.Role(role), vcsec.KeyFormFactor(formFactor)); err != nil { + if err := vehicle.SendAddKeyRequestWithRole(ctx, publicKey, keys.Role(role), vcsec.KeyFormFactor(formFactor)); err != nil { return err } - fmt.Printf("Sent add-key request to %s. Confirm by tapping NFC card on center console.\n", car.VIN()) + fmt.Printf("Sent add-key request to %s. Confirm by tapping NFC vehicled on center console.\n", vehicle.VIN()) return nil }, }, @@ -365,12 +368,12 @@ var commands = map[string]*Command{ args: []Argument{ Argument{name: "PUBLIC_KEY", help: "file containing public key (or corresponding private key)"}, }, - handler: func(ctx context.Context, acct *account.Account, car *vehicle.Vehicle, args map[string]string) error { + handler: func(ctx context.Context, acct *account.Account, vehicle *vehicle.Vehicle, args map[string]string) error { publicKey, err := protocol.LoadPublicKey(args["PUBLIC_KEY"]) if err != nil { return fmt.Errorf("invalid public key: %s", err) } - return car.RemoveKey(ctx, publicKey) + return vehicle.RemoveKey(ctx, publicKey) }, }, "rename-key": &Command{ @@ -381,7 +384,7 @@ var commands = map[string]*Command{ Argument{name: "PUBLIC_KEY", help: "file containing public key (or corresponding private key)"}, Argument{name: "NAME", help: "New human-readable name for the public key (e.g., Dave's Phone)"}, }, - handler: func(ctx context.Context, acct *account.Account, car *vehicle.Vehicle, args map[string]string) error { + handler: func(ctx context.Context, acct *account.Account, vehicle *vehicle.Vehicle, args map[string]string) error { publicKey, err := protocol.LoadPublicKey(args["PUBLIC_KEY"]) if err != nil { return fmt.Errorf("invalid public key: %s", err) @@ -396,7 +399,7 @@ var commands = map[string]*Command{ args: []Argument{ Argument{name: "ENDPOINT", help: "Fleet API endpoint"}, }, - handler: func(ctx context.Context, acct *account.Account, car *vehicle.Vehicle, args map[string]string) error { + handler: func(ctx context.Context, acct *account.Account, vehicle *vehicle.Vehicle, args map[string]string) error { reply, err := acct.Get(ctx, args["ENDPOINT"]) if err != nil { return err @@ -415,7 +418,7 @@ var commands = map[string]*Command{ optional: []Argument{ Argument{name: "FILE", help: "JSON file to POST"}, }, - handler: func(ctx context.Context, acct *account.Account, car *vehicle.Vehicle, args map[string]string) error { + handler: func(ctx context.Context, acct *account.Account, vehicle *vehicle.Vehicle, args map[string]string) error { var jsonBytes []byte var err error if filename, ok := args["FILE"]; ok { @@ -441,8 +444,8 @@ var commands = map[string]*Command{ help: "List public keys enrolled on vehicle", requiresAuth: false, requiresFleetAPI: false, - handler: func(ctx context.Context, acct *account.Account, car *vehicle.Vehicle, args map[string]string) error { - summary, err := car.KeySummary(ctx) + handler: func(ctx context.Context, acct *account.Account, vehicle *vehicle.Vehicle, args map[string]string) error { + summary, err := vehicle.KeySummary(ctx) if err != nil { return err } @@ -450,7 +453,7 @@ var commands = map[string]*Command{ var details *vcsec.WhitelistEntryInfo for mask := summary.GetSlotMask(); mask > 0; mask >>= 1 { if mask&1 == 1 { - details, err = car.KeyInfoBySlot(ctx, slot) + details, err = vehicle.KeyInfoBySlot(ctx, slot) if err != nil { writeErr("Error fetching slot %d: %s", slot, err) if errors.Is(err, context.DeadlineExceeded) { @@ -470,23 +473,23 @@ var commands = map[string]*Command{ help: "Honk horn", requiresAuth: true, requiresFleetAPI: false, - handler: func(ctx context.Context, acct *account.Account, car *vehicle.Vehicle, args map[string]string) error { - return car.HonkHorn(ctx) + handler: func(ctx context.Context, acct *account.Account, vehicle *vehicle.Vehicle, args map[string]string) error { + return vehicle.ExecuteAction(ctx, action.HonkHorn()) }, }, "ping": &Command{ help: "Ping vehicle", requiresAuth: true, requiresFleetAPI: false, - handler: func(ctx context.Context, acct *account.Account, car *vehicle.Vehicle, args map[string]string) error { - return car.Ping(ctx) + handler: func(ctx context.Context, acct *account.Account, vehicle *vehicle.Vehicle, args map[string]string) error { + return vehicle.ExecuteAction(ctx, action.Ping()) }, }, "flash-lights": &Command{ help: "Flash lights", requiresAuth: true, - handler: func(ctx context.Context, acct *account.Account, car *vehicle.Vehicle, args map[string]string) error { - return car.FlashLights(ctx) + handler: func(ctx context.Context, acct *account.Account, vehicle *vehicle.Vehicle, args map[string]string) error { + return vehicle.ExecuteAction(ctx, action.FlashLights()) }, }, "charging-set-limit": &Command{ @@ -496,12 +499,12 @@ var commands = map[string]*Command{ args: []Argument{ Argument{name: "PERCENT", help: "Charging limit"}, }, - handler: func(ctx context.Context, acct *account.Account, car *vehicle.Vehicle, args map[string]string) error { + handler: func(ctx context.Context, acct *account.Account, vehicle *vehicle.Vehicle, args map[string]string) error { limit, err := strconv.Atoi(args["PERCENT"]) if err != nil { return fmt.Errorf("error parsing PERCENT") } - return car.ChangeChargeLimit(ctx, int32(limit)) + return vehicle.ExecuteAction(ctx, action.ChangeChargeLimit(int32(limit))) }, }, "charging-set-amps": &Command{ @@ -511,28 +514,28 @@ var commands = map[string]*Command{ args: []Argument{ Argument{name: "AMPS", help: "Charging current"}, }, - handler: func(ctx context.Context, acct *account.Account, car *vehicle.Vehicle, args map[string]string) error { + handler: func(ctx context.Context, acct *account.Account, vehicle *vehicle.Vehicle, args map[string]string) error { limit, err := strconv.Atoi(args["AMPS"]) if err != nil { return fmt.Errorf("error parsing AMPS") } - return car.SetChargingAmps(ctx, int32(limit)) + return vehicle.ExecuteAction(ctx, action.SetChargingAmps(int32(limit))) }, }, "charging-start": &Command{ help: "Start charging", requiresAuth: true, requiresFleetAPI: false, - handler: func(ctx context.Context, acct *account.Account, car *vehicle.Vehicle, args map[string]string) error { - return car.ChargeStart(ctx) + handler: func(ctx context.Context, acct *account.Account, vehicle *vehicle.Vehicle, args map[string]string) error { + return vehicle.ExecuteAction(ctx, action.ChargeStart()) }, }, "charging-stop": &Command{ help: "Stop charging", requiresAuth: true, requiresFleetAPI: false, - handler: func(ctx context.Context, acct *account.Account, car *vehicle.Vehicle, args map[string]string) error { - return car.ChargeStop(ctx) + handler: func(ctx context.Context, acct *account.Account, vehicle *vehicle.Vehicle, args map[string]string) error { + return vehicle.ExecuteAction(ctx, action.ChargeStop()) }, }, "charging-schedule": &Command{ @@ -542,22 +545,22 @@ var commands = map[string]*Command{ args: []Argument{ Argument{name: "MINS", help: "Time after midnight in minutes"}, }, - handler: func(ctx context.Context, acct *account.Account, car *vehicle.Vehicle, args map[string]string) error { + handler: func(ctx context.Context, acct *account.Account, vehicle *vehicle.Vehicle, args map[string]string) error { minutesAfterMidnight, err := strconv.Atoi(args["MINS"]) if err != nil { return fmt.Errorf("error parsing minutes") } // Convert minutes to a time.Duration chargingTime := time.Duration(minutesAfterMidnight) * time.Minute - return car.ScheduleCharging(ctx, true, chargingTime) + return vehicle.ExecuteAction(ctx, action.ScheduleCharging(true, chargingTime)) }, }, "charging-schedule-cancel": &Command{ help: "Cancel scheduled charge start", requiresAuth: true, requiresFleetAPI: false, - handler: func(ctx context.Context, acct *account.Account, car *vehicle.Vehicle, args map[string]string) error { - return car.ScheduleCharging(ctx, false, 0*time.Hour) + handler: func(ctx context.Context, acct *account.Account, vehicle *vehicle.Vehicle, args map[string]string) error { + return vehicle.ExecuteAction(ctx, action.ScheduleCharging(false, 0*time.Hour)) }, }, "media-set-volume": &Command{ @@ -567,12 +570,16 @@ var commands = map[string]*Command{ args: []Argument{ Argument{name: "VOLUME", help: "Set volume (0.0-10.0"}, }, - handler: func(ctx context.Context, acct *account.Account, car *vehicle.Vehicle, args map[string]string) error { + handler: func(ctx context.Context, acct *account.Account, vehicle *vehicle.Vehicle, args map[string]string) error { volume, err := strconv.ParseFloat(args["VOLUME"], 32) if err != nil { return fmt.Errorf("failed to parse volume") } - return car.SetVolume(ctx, float32(volume)) + setVolumeAction, err := action.SetVolume(float32(volume)) + if err != nil { + return err + } + return vehicle.ExecuteAction(ctx, setVolumeAction) }, }, "media-toggle-playback": &Command{ @@ -580,8 +587,8 @@ var commands = map[string]*Command{ requiresAuth: true, requiresFleetAPI: false, args: []Argument{}, - handler: func(ctx context.Context, acct *account.Account, car *vehicle.Vehicle, args map[string]string) error { - return car.ToggleMediaPlayback(ctx) + handler: func(ctx context.Context, acct *account.Account, vehicle *vehicle.Vehicle, args map[string]string) error { + return vehicle.ExecuteAction(ctx, action.ToggleMediaPlayback()) }, }, "software-update-start": &Command{ @@ -594,21 +601,21 @@ var commands = map[string]*Command{ help: "Time to wait before starting update. Examples: 2h, 10m.", }, }, - handler: func(ctx context.Context, acct *account.Account, car *vehicle.Vehicle, args map[string]string) error { + handler: func(ctx context.Context, acct *account.Account, vehicle *vehicle.Vehicle, args map[string]string) error { delay, err := time.ParseDuration(args["DELAY"]) if err != nil { return fmt.Errorf("error parsing DELAY. Valid times are , where is a number (decimals are allowed) and is 's, 'm', or 'h'") // ...or 'ns'/'µs' if that's your cup of tea. } - return car.ScheduleSoftwareUpdate(ctx, delay) + return vehicle.ExecuteAction(ctx, action.ScheduleSoftwareUpdate(delay)) }, }, "software-update-cancel": &Command{ help: "Cancel a pending software update", requiresAuth: true, requiresFleetAPI: false, - handler: func(ctx context.Context, acct *account.Account, car *vehicle.Vehicle, args map[string]string) error { - return car.CancelSoftwareUpdate(ctx) + handler: func(ctx context.Context, acct *account.Account, vehicle *vehicle.Vehicle, args map[string]string) error { + return vehicle.ExecuteAction(ctx, action.CancelSoftwareUpdate()) }, }, "sentry-mode": &Command{ @@ -618,7 +625,7 @@ var commands = map[string]*Command{ args: []Argument{ Argument{name: "STATE", help: "'on' or 'off'"}, }, - handler: func(ctx context.Context, acct *account.Account, car *vehicle.Vehicle, args map[string]string) error { + handler: func(ctx context.Context, acct *account.Account, vehicle *vehicle.Vehicle, args map[string]string) error { var state bool switch args["STATE"] { case "on": @@ -628,93 +635,93 @@ var commands = map[string]*Command{ default: return fmt.Errorf("sentry mode state must be 'on' or 'off'") } - return car.SetSentryMode(ctx, state) + return vehicle.ExecuteAction(ctx, action.SetSentryMode(state)) }, }, "wake": &Command{ help: "Wake up vehicle", requiresAuth: false, requiresFleetAPI: false, - handler: func(ctx context.Context, acct *account.Account, car *vehicle.Vehicle, args map[string]string) error { - return car.Wakeup(ctx) + handler: func(ctx context.Context, acct *account.Account, vehicle *vehicle.Vehicle, args map[string]string) error { + return vehicle.Wakeup(ctx) }, }, "tonneau-open": &Command{ help: "Open Cybertruck tonneau.", requiresAuth: true, requiresFleetAPI: false, - handler: func(ctx context.Context, acct *account.Account, car *vehicle.Vehicle, args map[string]string) error { - return car.OpenTonneau(ctx) + handler: func(ctx context.Context, acct *account.Account, vehicle *vehicle.Vehicle, args map[string]string) error { + return vehicle.ExecuteAction(ctx, action.OpenTonneau()) }, }, "tonneau-close": &Command{ help: "Close Cybertruck tonneau.", requiresAuth: true, requiresFleetAPI: false, - handler: func(ctx context.Context, acct *account.Account, car *vehicle.Vehicle, args map[string]string) error { - return car.CloseTonneau(ctx) + handler: func(ctx context.Context, acct *account.Account, vehicle *vehicle.Vehicle, args map[string]string) error { + return vehicle.ExecuteAction(ctx, action.CloseTonneau()) }, }, "tonneau-stop": &Command{ help: "Stop moving Cybertruck tonneau.", requiresAuth: true, requiresFleetAPI: false, - handler: func(ctx context.Context, acct *account.Account, car *vehicle.Vehicle, args map[string]string) error { - return car.StopTonneau(ctx) + handler: func(ctx context.Context, acct *account.Account, vehicle *vehicle.Vehicle, args map[string]string) error { + return vehicle.ExecuteAction(ctx, action.StopTonneau()) }, }, "trunk-open": &Command{ help: "Open vehicle trunk. Note that trunk-close only works on certain vehicle types.", requiresAuth: true, requiresFleetAPI: false, - handler: func(ctx context.Context, acct *account.Account, car *vehicle.Vehicle, args map[string]string) error { - return car.OpenTrunk(ctx) + handler: func(ctx context.Context, acct *account.Account, vehicle *vehicle.Vehicle, args map[string]string) error { + return vehicle.ExecuteAction(ctx, action.OpenTrunk()) }, }, "trunk-move": &Command{ help: "Toggle trunk open/closed. Closing is only available on certain vehicle types.", requiresAuth: true, requiresFleetAPI: false, - handler: func(ctx context.Context, acct *account.Account, car *vehicle.Vehicle, args map[string]string) error { - return car.ActuateTrunk(ctx) + handler: func(ctx context.Context, acct *account.Account, vehicle *vehicle.Vehicle, args map[string]string) error { + return vehicle.ExecuteAction(ctx, action.ActuateTrunk()) }, }, "trunk-close": &Command{ help: "Closes vehicle trunk. Only available on certain vehicle types.", requiresAuth: true, requiresFleetAPI: false, - handler: func(ctx context.Context, acct *account.Account, car *vehicle.Vehicle, args map[string]string) error { - return car.CloseTrunk(ctx) + handler: func(ctx context.Context, acct *account.Account, vehicle *vehicle.Vehicle, args map[string]string) error { + return vehicle.ExecuteAction(ctx, action.CloseTrunk()) }, }, "frunk-open": &Command{ help: "Open vehicle frunk. Note that there's no frunk-close command!", requiresAuth: true, requiresFleetAPI: false, - handler: func(ctx context.Context, acct *account.Account, car *vehicle.Vehicle, args map[string]string) error { - return car.OpenFrunk(ctx) + handler: func(ctx context.Context, acct *account.Account, vehicle *vehicle.Vehicle, args map[string]string) error { + return vehicle.ExecuteAction(ctx, action.OpenFrunk()) }, }, "charge-port-open": &Command{ help: "Open charge port", requiresAuth: true, requiresFleetAPI: false, - handler: func(ctx context.Context, acct *account.Account, car *vehicle.Vehicle, args map[string]string) error { - return car.OpenChargePort(ctx) + handler: func(ctx context.Context, acct *account.Account, vehicle *vehicle.Vehicle, args map[string]string) error { + return vehicle.ExecuteAction(ctx, action.OpenChargePort()) }, }, "charge-port-close": &Command{ help: "Close charge port", requiresAuth: true, requiresFleetAPI: false, - handler: func(ctx context.Context, acct *account.Account, car *vehicle.Vehicle, args map[string]string) error { - return car.CloseChargePort(ctx) + handler: func(ctx context.Context, acct *account.Account, vehicle *vehicle.Vehicle, args map[string]string) error { + return vehicle.ExecuteAction(ctx, action.CloseChargePort()) }, }, "autosecure-modelx": &Command{ help: "Close falcon-wing doors and lock vehicle. Model X only.", - handler: func(ctx context.Context, acct *account.Account, car *vehicle.Vehicle, args map[string]string) error { - return car.AutoSecureVehicle(ctx) + handler: func(ctx context.Context, acct *account.Account, vehicle *vehicle.Vehicle, args map[string]string) error { + return vehicle.ExecuteAction(ctx, action.AutoSecureVehicle()) }, }, "session-info": &Command{ @@ -725,7 +732,7 @@ var commands = map[string]*Command{ Argument{name: "PUBLIC_KEY", help: "file containing public key (or corresponding private key)"}, Argument{name: "DOMAIN", help: "'vcsec' or 'infotainment'"}, }, - handler: func(ctx context.Context, acct *account.Account, car *vehicle.Vehicle, args map[string]string) error { + handler: func(ctx context.Context, acct *account.Account, vehicle *vehicle.Vehicle, args map[string]string) error { // See SeatPosition definition for controlling backrest heaters (limited models). domains := map[string]protocol.Domain{ "vcsec": protocol.DomainVCSEC, @@ -739,7 +746,7 @@ var commands = map[string]*Command{ if err != nil { return fmt.Errorf("invalid public key: %s", err) } - info, err := car.SessionInfo(ctx, publicKey, domain) + info, err := vehicle.SessionInfo(ctx, publicKey, domain) if err != nil { return err } @@ -755,35 +762,35 @@ var commands = map[string]*Command{ Argument{name: "SEAT", help: "- (e.g., 2nd-row-left)"}, Argument{name: "LEVEL", help: "off, low, medium, or high"}, }, - handler: func(ctx context.Context, acct *account.Account, car *vehicle.Vehicle, args map[string]string) error { + handler: func(ctx context.Context, acct *account.Account, vehicle *vehicle.Vehicle, args map[string]string) error { // See SeatPosition definition for controlling backrest heaters (limited models). - seats := map[string]vehicle.SeatPosition{ - "front-left": vehicle.SeatFrontLeft, - "front-right": vehicle.SeatFrontRight, - "2nd-row-left": vehicle.SeatSecondRowLeft, - "2nd-row-center": vehicle.SeatSecondRowCenter, - "2nd-row-right": vehicle.SeatSecondRowRight, - "3rd-row-left": vehicle.SeatThirdRowLeft, - "3rd-row-right": vehicle.SeatThirdRowRight, + seats := map[string]action.SeatPosition{ + "front-left": action.SeatFrontLeft, + "front-right": action.SeatFrontRight, + "2nd-row-left": action.SeatSecondRowLeft, + "2nd-row-center": action.SeatSecondRowCenter, + "2nd-row-right": action.SeatSecondRowRight, + "3rd-row-left": action.SeatThirdRowLeft, + "3rd-row-right": action.SeatThirdRowRight, } position, ok := seats[args["SEAT"]] if !ok { return fmt.Errorf("invalid seat position") } - levels := map[string]vehicle.Level{ - "off": vehicle.LevelOff, - "low": vehicle.LevelLow, - "medium": vehicle.LevelMed, - "high": vehicle.LevelHigh, + levels := map[string]action.Level{ + "off": action.LevelOff, + "low": action.LevelLow, + "medium": action.LevelMed, + "high": action.LevelHigh, } level, ok := levels[args["LEVEL"]] if !ok { return fmt.Errorf("invalid seat heater level") } - spec := map[vehicle.SeatPosition]vehicle.Level{ + spec := map[action.SeatPosition]action.Level{ position: level, } - return car.SetSeatHeater(ctx, spec) + return vehicle.ExecuteAction(ctx, action.SetSeatHeater(spec)) }, }, "steering-wheel-heater": &Command{ @@ -793,7 +800,7 @@ var commands = map[string]*Command{ args: []Argument{ Argument{name: "STATE", help: "'on' or 'off'"}, }, - handler: func(ctx context.Context, acct *account.Account, car *vehicle.Vehicle, args map[string]string) error { + handler: func(ctx context.Context, acct *account.Account, vehicle *vehicle.Vehicle, args map[string]string) error { var state bool switch args["STATE"] { case "on": @@ -803,14 +810,14 @@ var commands = map[string]*Command{ default: return fmt.Errorf("steering wheel state must be 'on' or 'off'") } - return car.SetSteeringWheelHeater(ctx, state) + return vehicle.ExecuteAction(ctx, action.SetSteeringWheelHeater(state)) }, }, "product-info": &Command{ help: "Print JSON product info", requiresAuth: false, requiresFleetAPI: true, - handler: func(ctx context.Context, acct *account.Account, car *vehicle.Vehicle, args map[string]string) error { + handler: func(ctx context.Context, acct *account.Account, vehicle *vehicle.Vehicle, args map[string]string) error { productsJSON, err := acct.Get(ctx, "api/1/products") if err != nil { return err @@ -829,13 +836,13 @@ var commands = map[string]*Command{ optional: []Argument{ Argument{name: "STATE", help: "'on' (default) or 'off'"}, }, - handler: func(ctx context.Context, acct *account.Account, car *vehicle.Vehicle, args map[string]string) error { - var positions []vehicle.SeatPosition + handler: func(ctx context.Context, acct *account.Account, vehicle *vehicle.Vehicle, args map[string]string) error { + var positions []action.SeatPosition if strings.Contains(args["POSITIONS"], "L") { - positions = append(positions, vehicle.SeatFrontLeft) + positions = append(positions, action.SeatFrontLeft) } if strings.Contains(args["POSITIONS"], "R") { - positions = append(positions, vehicle.SeatFrontRight) + positions = append(positions, action.SeatFrontRight) } if len(positions) != len(args["POSITIONS"]) { return fmt.Errorf("invalid seat position") @@ -844,23 +851,23 @@ var commands = map[string]*Command{ if state, ok := args["STATE"]; ok && strings.ToUpper(state) == "OFF" { enabled = false } - return car.AutoSeatAndClimate(ctx, positions, enabled) + return vehicle.ExecuteAction(ctx, action.AutoSeatAndClimate(positions, enabled)) }, }, "windows-vent": &Command{ help: "Vent all windows", requiresAuth: true, requiresFleetAPI: false, - handler: func(ctx context.Context, acct *account.Account, car *vehicle.Vehicle, args map[string]string) error { - return car.VentWindows(ctx) + handler: func(ctx context.Context, acct *account.Account, vehicle *vehicle.Vehicle, args map[string]string) error { + return vehicle.ExecuteAction(ctx, action.VentWindows()) }, }, "windows-close": &Command{ help: "Close all windows", requiresAuth: true, requiresFleetAPI: false, - handler: func(ctx context.Context, acct *account.Account, car *vehicle.Vehicle, args map[string]string) error { - return car.CloseWindows(ctx) + handler: func(ctx context.Context, acct *account.Account, vehicle *vehicle.Vehicle, args map[string]string) error { + return vehicle.ExecuteAction(ctx, action.CloseWindows()) }, }, "body-controller-state": &Command{ @@ -868,8 +875,8 @@ var commands = map[string]*Command{ domain: protocol.DomainVCSEC, requiresAuth: false, requiresFleetAPI: false, - handler: func(ctx context.Context, acct *account.Account, car *vehicle.Vehicle, args map[string]string) error { - info, err := car.BodyControllerState(ctx) + handler: func(ctx context.Context, acct *account.Account, vehicle *vehicle.Vehicle, args map[string]string) error { + info, err := vehicle.BodyControllerState(ctx) if err != nil { return err } @@ -887,8 +894,8 @@ var commands = map[string]*Command{ help: "Erase Guest Mode user data", requiresAuth: true, requiresFleetAPI: false, - handler: func(ctx context.Context, acct *account.Account, car *vehicle.Vehicle, args map[string]string) error { - return car.EraseGuestData(ctx) + handler: func(ctx context.Context, acct *account.Account, vehicle *vehicle.Vehicle, args map[string]string) error { + return vehicle.ExecuteAction(ctx, action.EraseGuestData()) }, }, "charging-schedule-add": &Command{ @@ -906,9 +913,9 @@ var commands = map[string]*Command{ Argument{name: "ID", help: "The ID of the charge schedule to modify. Not required for new schedules."}, Argument{name: "ENABLED", help: "Whether the charge schedule is enabled. Expects 'true' or 'false'. Defaults to true."}, }, - handler: func(ctx context.Context, acct *account.Account, car *vehicle.Vehicle, args map[string]string) error { + handler: func(ctx context.Context, acct *account.Account, vehicle *vehicle.Vehicle, args map[string]string) error { var err error - schedule := vehicle.ChargeSchedule{ + schedule := carserver.ChargeSchedule{ Id: uint64(time.Now().Unix()), Enabled: true, } @@ -957,7 +964,7 @@ var commands = map[string]*Command{ schedule.OneTime = true } - if err := car.AddChargeSchedule(ctx, &schedule); err != nil { + if err := vehicle.ExecuteAction(ctx, action.AddChargeSchedule(&schedule)); err != nil { return err } fmt.Printf("%d\n", schedule.Id) @@ -974,7 +981,7 @@ var commands = map[string]*Command{ optional: []Argument{ Argument{name: "ID", help: "numeric ID of schedule to remove when TYPE set to id"}, }, - handler: func(ctx context.Context, acct *account.Account, car *vehicle.Vehicle, args map[string]string) error { + handler: func(ctx context.Context, acct *account.Account, vehicle *vehicle.Vehicle, args map[string]string) error { var home, work, other bool switch strings.ToUpper(args["TYPE"]) { case "ID": @@ -983,7 +990,7 @@ var commands = map[string]*Command{ if err != nil { return errors.New("expected numeric ID") } - return car.RemoveChargeSchedule(ctx, id) + return vehicle.ExecuteAction(ctx, action.RemoveChargeSchedule(id)) } else { return errors.New("missing schedule ID") } @@ -996,7 +1003,7 @@ var commands = map[string]*Command{ default: return errors.New("TYPE must be home|work|other|id") } - return car.BatchRemoveChargeSchedules(ctx, home, work, other) + return vehicle.ExecuteAction(ctx, action.BatchRemoveChargeSchedules(home, work, other)) }, }, "precondition-schedule-add": &Command{ @@ -1014,9 +1021,9 @@ var commands = map[string]*Command{ Argument{name: "ID", help: "The ID of the precondition schedule to modify. Not required for new schedules."}, Argument{name: "ENABLED", help: "Whether the precondition schedule is enabled. Expects 'true' or 'false'. Defaults to true."}, }, - handler: func(ctx context.Context, acct *account.Account, car *vehicle.Vehicle, args map[string]string) error { + handler: func(ctx context.Context, acct *account.Account, vehicle *vehicle.Vehicle, args map[string]string) error { var err error - schedule := vehicle.PreconditionSchedule{ + schedule := carserver.PreconditionSchedule{ Id: uint64(time.Now().Unix()), Enabled: true, } @@ -1058,7 +1065,7 @@ var commands = map[string]*Command{ schedule.OneTime = true } - if err := car.AddPreconditionSchedule(ctx, &schedule); err != nil { + if err := vehicle.ExecuteAction(ctx, action.AddPreconditionSchedule(&schedule)); err != nil { return err } fmt.Printf("%d\n", schedule.Id) @@ -1075,7 +1082,7 @@ var commands = map[string]*Command{ optional: []Argument{ Argument{name: "ID", help: "numeric ID of schedule to remove when TYPE set to id"}, }, - handler: func(ctx context.Context, acct *account.Account, car *vehicle.Vehicle, args map[string]string) error { + handler: func(ctx context.Context, acct *account.Account, vehicle *vehicle.Vehicle, args map[string]string) error { var home, work, other bool switch strings.ToUpper(args["TYPE"]) { case "ID": @@ -1084,7 +1091,7 @@ var commands = map[string]*Command{ if err != nil { return errors.New("expected numeric ID") } - return car.RemoveChargeSchedule(ctx, id) + return vehicle.ExecuteAction(ctx, action.RemoveChargeSchedule(id)) } else { return errors.New("missing schedule ID") } @@ -1097,7 +1104,7 @@ var commands = map[string]*Command{ default: return errors.New("TYPE must be home|work|other|id") } - return car.BatchRemovePreconditionSchedules(ctx, home, work, other) + return vehicle.ExecuteAction(ctx, action.BatchRemovePreconditionSchedules(home, work, other)) }, }, } diff --git a/cmd/tesla-http-proxy/main.go b/cmd/tesla-http-proxy/main.go index a0b69bd..f62ae86 100644 --- a/cmd/tesla-http-proxy/main.go +++ b/cmd/tesla-http-proxy/main.go @@ -10,7 +10,10 @@ import ( "strconv" "time" + "github.com/teslamotors/vehicle-command/internal/authentication" "github.com/teslamotors/vehicle-command/internal/log" + "github.com/teslamotors/vehicle-command/pkg/account" + "github.com/teslamotors/vehicle-command/pkg/cache" "github.com/teslamotors/vehicle-command/pkg/cli" "github.com/teslamotors/vehicle-command/pkg/protocol" "github.com/teslamotors/vehicle-command/pkg/proxy" @@ -68,6 +71,14 @@ func Usage() { flag.PrintDefaults() } +type proxyAccountProvider struct { + *account.Account +} + +func (p *proxyAccountProvider) GetVehicle(ctx context.Context, id string, key authentication.ECDHPrivateKey, cache *cache.SessionCache) (proxy.Vehicle, error) { + return p.Account.GetVehicle(ctx, id, key, cache) +} + func main() { config, err := cli.NewConfig(cli.FlagPrivateKey) @@ -113,10 +124,10 @@ func main() { } log.Debug("Creating proxy") - p, err := proxy.New(context.Background(), skey, cacheSize) - if err != nil { - return - } + p := proxy.New(context.Background(), skey, cacheSize, func(oauthToken, userAgent string) (proxy.Account, error) { + a, b := account.New(oauthToken, userAgent) + return &proxyAccountProvider{a}, b + }) p.Timeout = httpConfig.timeout addr := fmt.Sprintf("%s:%d", httpConfig.host, httpConfig.port) log.Info("Listening on %s", addr) diff --git a/examples/ble/main.go b/examples/ble/main.go index 59a91b2..f3edf3c 100644 --- a/examples/ble/main.go +++ b/examples/ble/main.go @@ -12,6 +12,7 @@ import ( debugger "github.com/teslamotors/vehicle-command/internal/log" + "github.com/teslamotors/vehicle-command/pkg/action" "github.com/teslamotors/vehicle-command/pkg/connector/ble" "github.com/teslamotors/vehicle-command/pkg/protocol" "github.com/teslamotors/vehicle-command/pkg/vehicle" @@ -86,14 +87,14 @@ func main() { } fmt.Println("Unlocking car...") - if err := car.Unlock(ctx); err != nil { + if err := car.ExecuteAction(ctx, action.Unlock()); err != nil { logger.Printf("Failed to unlock vehicle: %s\n", err) return } fmt.Println("Vehicle unlocked!") fmt.Println("Turning on HVAC...") - if err := car.ClimateOn(ctx); err != nil { + if err := car.ExecuteAction(ctx, action.ClimateOn()); err != nil { logger.Printf("Failed to turn on HVAC: %s\n", err) return } diff --git a/examples/unlock/unlock.go b/examples/unlock/unlock.go index 13656da..154cd6d 100644 --- a/examples/unlock/unlock.go +++ b/examples/unlock/unlock.go @@ -9,6 +9,7 @@ import ( "time" "github.com/teslamotors/vehicle-command/pkg/account" + "github.com/teslamotors/vehicle-command/pkg/action" "github.com/teslamotors/vehicle-command/pkg/protocol" ) @@ -90,7 +91,7 @@ func main() { } fmt.Println("Unlocking car...") - if err := car.Unlock(ctx); err != nil { + if err := car.ExecuteAction(ctx, action.Unlock()); err != nil { if protocol.MayHaveSucceeded(err) { logger.Printf("Unlock command sent, but client could not confirm receipt: %s\n", err) } else { diff --git a/go.mod b/go.mod index b53144b..a11cf64 100644 --- a/go.mod +++ b/go.mod @@ -8,7 +8,8 @@ require ( github.com/go-ble/ble v0.0.0-20220207185428-60d1eecf2633 github.com/golang-jwt/jwt/v5 v5.2.1 github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510 - golang.org/x/term v0.5.0 + go.uber.org/mock v0.4.0 + golang.org/x/term v0.23.0 google.golang.org/protobuf v1.34.2 ) @@ -17,19 +18,30 @@ require ( github.com/JuulLabs-OSS/cbgo v0.0.1 // indirect github.com/danieljoos/wincred v1.2.0 // indirect github.com/dvsekhvalnov/jose2go v1.6.0 // indirect + github.com/go-logr/logr v1.4.2 // indirect + github.com/go-task/slim-sprig/v3 v3.0.0 // indirect github.com/godbus/dbus v0.0.0-20190726142602-4481cbc300e2 // indirect - github.com/google/go-cmp v0.5.8 // indirect + github.com/google/go-cmp v0.6.0 // indirect + github.com/google/pprof v0.0.0-20240727154555-813a5fbdbec8 // indirect github.com/gsterjov/go-libsecret v0.0.0-20161001094733-a6f4afe4910c // indirect + github.com/jarcoal/httpmock v1.3.1 github.com/konsorten/go-windows-terminal-sequences v1.0.1 // indirect github.com/mattn/go-colorable v0.1.6 // indirect github.com/mattn/go-isatty v0.0.12 // indirect github.com/mgutz/ansi v0.0.0-20170206155736-9520e82c474b // indirect github.com/mgutz/logxi v0.0.0-20161027140823-aebf8a7d67ab // indirect github.com/mtibben/percent v0.2.1 // indirect + github.com/onsi/ginkgo/v2 v2.20.1 + github.com/onsi/gomega v1.34.1 github.com/pkg/errors v0.8.1 // indirect github.com/raff/goble v0.0.0-20190909174656-72afc67d6a99 // indirect github.com/sirupsen/logrus v1.5.0 // indirect - golang.org/x/sys v0.8.0 // indirect + golang.org/x/exp v0.0.0-20240719175910-8a7402abbf56 // indirect + golang.org/x/net v0.28.0 // indirect + golang.org/x/sys v0.23.0 // indirect + golang.org/x/text v0.17.0 // indirect + golang.org/x/tools v0.24.0 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect ) replace github.com/JuulLabs-OSS/cbgo => github.com/tinygo-org/cbgo v0.0.4 diff --git a/go.sum b/go.sum index 1c626be..e8f9872 100644 --- a/go.sum +++ b/go.sum @@ -15,16 +15,24 @@ github.com/dvsekhvalnov/jose2go v1.6.0 h1:Y9gnSnP4qEI0+/uQkHvFXeD2PLPJeXEL+ySMEA github.com/dvsekhvalnov/jose2go v1.6.0/go.mod h1:QsHjhyTlD/lAVqn/NSbVZmSCGeDehTB/mPZadG+mhXU= github.com/go-ble/ble v0.0.0-20220207185428-60d1eecf2633 h1:ZrzoZQz1CF33SPHLkjRpnVuZwr9cO1lTEc4Js7SgBos= github.com/go-ble/ble v0.0.0-20220207185428-60d1eecf2633/go.mod h1:fFJl/jD/uyILGBeD5iQ8tYHrPlJafyqCJzAyTHNJ1Uk= +github.com/go-logr/logr v1.4.2 h1:6pFjapn8bFcIbiKo3XT4j/BhANplGihG6tvd+8rYgrY= +github.com/go-logr/logr v1.4.2/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= +github.com/go-task/slim-sprig/v3 v3.0.0 h1:sUs3vkvUymDpBKi3qH1YSqBQk9+9D/8M2mN1vB6EwHI= +github.com/go-task/slim-sprig/v3 v3.0.0/go.mod h1:W848ghGpv3Qj3dhTPRyJypKRiqCdHZiAzKg9hl15HA8= github.com/godbus/dbus v0.0.0-20190726142602-4481cbc300e2 h1:ZpnhV/YsD2/4cESfV5+Hoeu/iUR3ruzNvZ+yQfO03a0= github.com/godbus/dbus v0.0.0-20190726142602-4481cbc300e2/go.mod h1:bBOAhwG1umN6/6ZUMtDFBMQR8jRg9O75tm9K00oMsK4= github.com/golang-jwt/jwt/v5 v5.2.1 h1:OuVbFODueb089Lh128TAcimifWaLhJwVflnrgM17wHk= github.com/golang-jwt/jwt/v5 v5.2.1/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk= -github.com/google/go-cmp v0.5.8 h1:e6P7q2lk1O+qJJb4BtCQXlK8vWEO8V1ZeuEdJNOqZyg= -github.com/google/go-cmp v0.5.8/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= +github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +github.com/google/pprof v0.0.0-20240727154555-813a5fbdbec8 h1:FKHo8hFI3A+7w0aUQuYXQ+6EN5stWmeY/AZqtM8xk9k= +github.com/google/pprof v0.0.0-20240727154555-813a5fbdbec8/go.mod h1:K1liHPHnj73Fdn/EKuT8nrFqBihUSKXoLYU0BuatOYo= github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510 h1:El6M4kTTCOh6aBiKaUGG7oYTSPP8MxqL4YI3kZKwcP4= github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510/go.mod h1:pupxD2MaaD3pAXIBCelhxNneeOaAeabZDe5s4K6zSpQ= github.com/gsterjov/go-libsecret v0.0.0-20161001094733-a6f4afe4910c h1:6rhixN/i8ZofjG1Y75iExal34USq5p+wiN1tpie8IrU= github.com/gsterjov/go-libsecret v0.0.0-20161001094733-a6f4afe4910c/go.mod h1:NMPJylDgVpX0MLRlPy15sqSwOFv/U1GZ2m21JhFfek0= +github.com/jarcoal/httpmock v1.3.1 h1:iUx3whfZWVf3jT01hQTO/Eo5sAYtB2/rqaUuOtpInww= +github.com/jarcoal/httpmock v1.3.1/go.mod h1:3yb8rc4BI7TCBhFY8ng0gjuLKJNquuDNiPaZjnENuYg= github.com/konsorten/go-windows-terminal-sequences v1.0.1 h1:mweAR1A6xJ3oS2pRaGiHgQ4OO8tzTaLawm8vnODuwDk= github.com/konsorten/go-windows-terminal-sequences v1.0.1/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ= github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= @@ -34,6 +42,8 @@ github.com/mattn/go-colorable v0.1.6 h1:6Su7aK7lXmJ/U79bYtBjLNaha4Fs1Rg9plHpcH+v github.com/mattn/go-colorable v0.1.6/go.mod h1:u6P/XSegPjTcexA+o6vUJrdnUu04hMope9wVRipJSqc= github.com/mattn/go-isatty v0.0.12 h1:wuysRhFDzyxgEmMf5xjvJ2M9dZoWAXNNr5LSBS7uHXY= github.com/mattn/go-isatty v0.0.12/go.mod h1:cbi8OIDigv2wuxKPP5vlRcQ1OAZbq2CE4Kysco4FUpU= +github.com/maxatome/go-testdeep v1.12.0 h1:Ql7Go8Tg0C1D/uMMX59LAoYK7LffeJQ6X2T04nTH68g= +github.com/maxatome/go-testdeep v1.12.0/go.mod h1:lPZc/HAcJMP92l7yI6TRz1aZN5URwUBUAfUNvrclaNM= github.com/mgutz/ansi v0.0.0-20170206155736-9520e82c474b h1:j7+1HpAFS1zy5+Q4qx1fWh90gTKwiN4QCGoY9TWyyO4= github.com/mgutz/ansi v0.0.0-20170206155736-9520e82c474b/go.mod h1:01TrycV0kFyexm33Z7vhZRXopbI8J3TDReVlkTgMUxE= github.com/mgutz/logxi v0.0.0-20161027140823-aebf8a7d67ab h1:n8cgpHzJ5+EDyDri2s/GC7a9+qK3/YEGnBsd0uS/8PY= @@ -42,6 +52,10 @@ github.com/mtibben/percent v0.2.1 h1:5gssi8Nqo8QU/r2pynCm+hBQHpkB/uNK7BJCFogWdzs github.com/mtibben/percent v0.2.1/go.mod h1:KG9uO+SZkUp+VkRHsCdYQV3XSZrrSpR3O9ibNBTZrns= github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e h1:fD57ERR4JtEqsWbfPhv4DMiApHyliiK5xCTNVSPiaAs= github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e/go.mod h1:zD1mROLANZcx1PVRCS0qkT7pwLkGfwJo4zjcN/Tysno= +github.com/onsi/ginkgo/v2 v2.20.1 h1:YlVIbqct+ZmnEph770q9Q7NVAz4wwIiVNahee6JyUzo= +github.com/onsi/ginkgo/v2 v2.20.1/go.mod h1:lG9ey2Z29hR41WMVthyJBGUBcBhGOtoPF2VFMvBXFCI= +github.com/onsi/gomega v1.34.1 h1:EUMJIKUjM8sKjYbtxQI9A4z2o+rruxnzNvpknOXie6k= +github.com/onsi/gomega v1.34.1/go.mod h1:kU1QgUvBDLXBJq618Xvm2LUX6rSAfRaFRTcdOeDLwwY= github.com/pkg/errors v0.8.1 h1:iURUrRGxPUNPdy5/HRSm+Yj6okJ6UtLINN0Q9M4+h3I= github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= @@ -57,19 +71,29 @@ github.com/stretchr/objx v0.5.0 h1:1zr/of2m5FGMsad5YfcqgdqdWrIhu+EBEJRhR1U7z/c= github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= -github.com/stretchr/testify v1.8.1 h1:w7B6lhMri9wdJUVmEZPGGhZzrYTPvgJArz7wNPgYKsk= -github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= +github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk= +github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= github.com/tinygo-org/cbgo v0.0.4 h1:3D76CRYbH03Rudi8sEgs/YO0x3JIMdyq8jlQtk/44fU= github.com/tinygo-org/cbgo v0.0.4/go.mod h1:7+HgWIHd4nbAz0ESjGlJ1/v9LDU1Ox8MGzP9mah/fLk= github.com/urfave/cli v1.22.2/go.mod h1:Gos4lmkARVdJ6EkW0WaNv/tZAAMe9V7XWyB60NtXRu0= +go.uber.org/mock v0.4.0 h1:VcM4ZOtdbR4f6VXfiOpwpVJDL6lCReaZ6mw31wqh7KU= +go.uber.org/mock v0.4.0/go.mod h1:a6FSlNadKUHUa9IP5Vyt1zh4fC7uAwxMutEAscFbkZc= +golang.org/x/exp v0.0.0-20240719175910-8a7402abbf56 h1:2dVuKD2vS7b0QIHQbpyTISPd0LeHDbnYEryqj5Q1ug8= +golang.org/x/exp v0.0.0-20240719175910-8a7402abbf56/go.mod h1:M4RDyNAINzryxdtnbRXRL/OHtkFuWGRjvuhBJpk2IlY= +golang.org/x/net v0.28.0 h1:a9JDOJc5GMUJ0+UDqmLT86WiEy7iWyIhz8gz8E4e5hE= +golang.org/x/net v0.28.0/go.mod h1:yqtgsTWOOnlGLG9GFRrK3++bGOUEkNBoHZc8MEDWPNg= golang.org/x/sys v0.0.0-20190422165155-953cdadca894/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200116001909-b77594299b42/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200223170610-d5e6a3e2c0ae/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20211204120058-94396e421777/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.8.0 h1:EBmGv8NaZBZTWvrbjNoL6HVt+IVy3QDQpJs7VRIw3tU= -golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/term v0.5.0 h1:n2a8QNdAb0sZNpU9R1ALUXBbY+w51fCQDN+7EdxNBsY= -golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k= +golang.org/x/sys v0.23.0 h1:YfKFowiIMvtgl1UERQoTPPToxltDeZfbj4H7dVUCwmM= +golang.org/x/sys v0.23.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/term v0.23.0 h1:F6D4vR+EHoL9/sWAWgAR1H2DcHr4PareCbAaCo1RpuU= +golang.org/x/term v0.23.0/go.mod h1:DgV24QBUrK6jhZXl+20l6UWznPlwAHm1Q1mGHtydmSk= +golang.org/x/text v0.17.0 h1:XtiM5bkSOt+ewxlOE/aE/AKEHibwj/6gvWMl9Rsh0Qc= +golang.org/x/text v0.17.0/go.mod h1:BuEKDfySbSR4drPmRPG/7iBdf8hvFMuRexcpahXilzY= +golang.org/x/tools v0.24.0 h1:J1shsA93PJUEVaUSaay7UXAyE8aimq3GW0pjlolpa24= +golang.org/x/tools v0.24.0/go.mod h1:YhNqVBIfWHdzvTLs0d8LCuMhkKUgSUKldakyV7W/WDQ= google.golang.org/protobuf v1.34.2 h1:6xV6lTsCfpGD21XK49h7MhtcApnLqkfYgPcdHftf6hg= google.golang.org/protobuf v1.34.2/go.mod h1:qYOHts0dSfpeUzUFpOMr/WGzszTmLH+DiWniOlNbLDw= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= diff --git a/mocks/proxy.go b/mocks/proxy.go new file mode 100644 index 0000000..abc7c20 --- /dev/null +++ b/mocks/proxy.go @@ -0,0 +1,164 @@ +// Code generated by MockGen. DO NOT EDIT. +// Source: pkg/proxy/proxy.go +// +// Generated by this command: +// +// mockgen -source=pkg/proxy/proxy.go -destination mocks/proxy.go -package=mocks -mock_names Vehicle=ProxyVehicle,Account=ProxyAccount +// + +// Package mocks is a generated GoMock package. +package mocks + +import ( + context "context" + reflect "reflect" + + authentication "github.com/teslamotors/vehicle-command/internal/authentication" + cache "github.com/teslamotors/vehicle-command/pkg/cache" + universalmessage "github.com/teslamotors/vehicle-command/pkg/protocol/protobuf/universalmessage" + proxy "github.com/teslamotors/vehicle-command/pkg/proxy" + gomock "go.uber.org/mock/gomock" +) + +// ProxyVehicle is a mock of Vehicle interface. +type ProxyVehicle struct { + ctrl *gomock.Controller + recorder *ProxyVehicleMockRecorder +} + +// ProxyVehicleMockRecorder is the mock recorder for ProxyVehicle. +type ProxyVehicleMockRecorder struct { + mock *ProxyVehicle +} + +// NewProxyVehicle creates a new mock instance. +func NewProxyVehicle(ctrl *gomock.Controller) *ProxyVehicle { + mock := &ProxyVehicle{ctrl: ctrl} + mock.recorder = &ProxyVehicleMockRecorder{mock} + return mock +} + +// EXPECT returns an object that allows the caller to indicate expected use. +func (m *ProxyVehicle) EXPECT() *ProxyVehicleMockRecorder { + return m.recorder +} + +// Connect mocks base method. +func (m *ProxyVehicle) Connect(arg0 context.Context) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "Connect", arg0) + ret0, _ := ret[0].(error) + return ret0 +} + +// Connect indicates an expected call of Connect. +func (mr *ProxyVehicleMockRecorder) Connect(arg0 any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Connect", reflect.TypeOf((*ProxyVehicle)(nil).Connect), arg0) +} + +// Disconnect mocks base method. +func (m *ProxyVehicle) Disconnect() { + m.ctrl.T.Helper() + m.ctrl.Call(m, "Disconnect") +} + +// Disconnect indicates an expected call of Disconnect. +func (mr *ProxyVehicleMockRecorder) Disconnect() *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Disconnect", reflect.TypeOf((*ProxyVehicle)(nil).Disconnect)) +} + +// ExecuteAction mocks base method. +func (m *ProxyVehicle) ExecuteAction(arg0 context.Context, arg1 any) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "ExecuteAction", arg0, arg1) + ret0, _ := ret[0].(error) + return ret0 +} + +// ExecuteAction indicates an expected call of ExecuteAction. +func (mr *ProxyVehicleMockRecorder) ExecuteAction(arg0, arg1 any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ExecuteAction", reflect.TypeOf((*ProxyVehicle)(nil).ExecuteAction), arg0, arg1) +} + +// StartSession mocks base method. +func (m *ProxyVehicle) StartSession(arg0 context.Context, arg1 []universalmessage.Domain) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "StartSession", arg0, arg1) + ret0, _ := ret[0].(error) + return ret0 +} + +// StartSession indicates an expected call of StartSession. +func (mr *ProxyVehicleMockRecorder) StartSession(arg0, arg1 any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "StartSession", reflect.TypeOf((*ProxyVehicle)(nil).StartSession), arg0, arg1) +} + +// UpdateCachedSessions mocks base method. +func (m *ProxyVehicle) UpdateCachedSessions(arg0 *cache.SessionCache) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "UpdateCachedSessions", arg0) + ret0, _ := ret[0].(error) + return ret0 +} + +// UpdateCachedSessions indicates an expected call of UpdateCachedSessions. +func (mr *ProxyVehicleMockRecorder) UpdateCachedSessions(arg0 any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UpdateCachedSessions", reflect.TypeOf((*ProxyVehicle)(nil).UpdateCachedSessions), arg0) +} + +// ProxyAccount is a mock of Account interface. +type ProxyAccount struct { + ctrl *gomock.Controller + recorder *ProxyAccountMockRecorder +} + +// ProxyAccountMockRecorder is the mock recorder for ProxyAccount. +type ProxyAccountMockRecorder struct { + mock *ProxyAccount +} + +// NewProxyAccount creates a new mock instance. +func NewProxyAccount(ctrl *gomock.Controller) *ProxyAccount { + mock := &ProxyAccount{ctrl: ctrl} + mock.recorder = &ProxyAccountMockRecorder{mock} + return mock +} + +// EXPECT returns an object that allows the caller to indicate expected use. +func (m *ProxyAccount) EXPECT() *ProxyAccountMockRecorder { + return m.recorder +} + +// GetHost mocks base method. +func (m *ProxyAccount) GetHost() string { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetHost") + ret0, _ := ret[0].(string) + return ret0 +} + +// GetHost indicates an expected call of GetHost. +func (mr *ProxyAccountMockRecorder) GetHost() *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetHost", reflect.TypeOf((*ProxyAccount)(nil).GetHost)) +} + +// GetVehicle mocks base method. +func (m *ProxyAccount) GetVehicle(arg0 context.Context, arg1 string, arg2 authentication.ECDHPrivateKey, arg3 *cache.SessionCache) (proxy.Vehicle, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetVehicle", arg0, arg1, arg2, arg3) + ret0, _ := ret[0].(proxy.Vehicle) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// GetVehicle indicates an expected call of GetVehicle. +func (mr *ProxyAccountMockRecorder) GetVehicle(arg0, arg1, arg2, arg3 any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetVehicle", reflect.TypeOf((*ProxyAccount)(nil).GetVehicle), arg0, arg1, arg2, arg3) +} diff --git a/pkg/account/account.go b/pkg/account/account.go index e038a49..130bee6 100644 --- a/pkg/account/account.go +++ b/pkg/account/account.go @@ -223,3 +223,8 @@ func (a *Account) UpdateKey(ctx context.Context, publicKey *ecdh.PublicKey, name _, err := a.sendFleetAPICommand(ctx, "api/1/users/keys", ¶ms) return err } + +// GetHost returns the hostname requests should be made to for the account. +func (a *Account) GetHost() string { + return a.Host +} diff --git a/pkg/action/action_suite_test.go b/pkg/action/action_suite_test.go new file mode 100644 index 0000000..7db2fc6 --- /dev/null +++ b/pkg/action/action_suite_test.go @@ -0,0 +1,13 @@ +package action + +import ( + "testing" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +func TestActions(t *testing.T) { + RegisterFailHandler(Fail) + RunSpecs(t, "Action Suite") +} diff --git a/pkg/action/charge.go b/pkg/action/charge.go new file mode 100644 index 0000000..cf6cb7b --- /dev/null +++ b/pkg/action/charge.go @@ -0,0 +1,308 @@ +package action + +import ( + "fmt" + "time" + + carserver "github.com/teslamotors/vehicle-command/pkg/protocol/protobuf/carserver" +) + +// ChargingPolicy controls when charging should occur. +type ChargingPolicy int + +const ( + ChargingPolicyOff ChargingPolicy = iota + ChargingPolicyAllDays + ChargingPolicyWeekdays +) + +// AddChargeSchedule adds a charge schedule. Requires firmware version 2024.26 or higher. +func AddChargeSchedule(schedule *carserver.ChargeSchedule) *carserver.Action_VehicleAction { + return &carserver.Action_VehicleAction{ + VehicleAction: &carserver.VehicleAction{ + VehicleActionMsg: &carserver.VehicleAction_AddChargeScheduleAction{ + AddChargeScheduleAction: schedule, + }, + }, + } +} + +// RemoveChargeSchedule removes a charge schedule by ID. Requires firmware version 2024.26 or higher. +func RemoveChargeSchedule(id uint64) *carserver.Action_VehicleAction { + return &carserver.Action_VehicleAction{ + VehicleAction: &carserver.VehicleAction{ + VehicleActionMsg: &carserver.VehicleAction_RemoveChargeScheduleAction{ + RemoveChargeScheduleAction: &carserver.RemoveChargeScheduleAction{ + Id: id, + }, + }, + }, + } +} + +// BatchRemoveChargeSchedules removes charge schedules for home, work, and other locations. +// Requires firmware version 2024.26 or higher. +func BatchRemoveChargeSchedules(home, work, other bool) *carserver.Action_VehicleAction { + return &carserver.Action_VehicleAction{ + VehicleAction: &carserver.VehicleAction{ + VehicleActionMsg: &carserver.VehicleAction_BatchRemoveChargeSchedulesAction{ + BatchRemoveChargeSchedulesAction: &carserver.BatchRemoveChargeSchedulesAction{ + Home: home, + Work: work, + Other: other, + }, + }, + }, + } +} + +// AddPreconditionSchedule adds a precondition schedule. +// Requires firmware version 2024.26 or higher. +func AddPreconditionSchedule(schedule *carserver.PreconditionSchedule) *carserver.Action_VehicleAction { + return &carserver.Action_VehicleAction{ + VehicleAction: &carserver.VehicleAction{ + VehicleActionMsg: &carserver.VehicleAction_AddPreconditionScheduleAction{ + AddPreconditionScheduleAction: schedule, + }, + }, + } +} + +// RemovePreconditionSchedule removes a precondition schedule by ID. +// Requires firmware version 2024.26 or higher. +func RemovePreconditionSchedule(id uint64) *carserver.Action_VehicleAction { + return &carserver.Action_VehicleAction{ + VehicleAction: &carserver.VehicleAction{ + VehicleActionMsg: &carserver.VehicleAction_RemovePreconditionScheduleAction{ + RemovePreconditionScheduleAction: &carserver.RemovePreconditionScheduleAction{ + Id: id, + }, + }, + }, + } +} + +// BatchRemovePreconditionSchedules removes precondition schedules for home, work, and other locations. Requires firmware version 2024.26 or higher. +func BatchRemovePreconditionSchedules(home, work, other bool) *carserver.Action_VehicleAction { + return &carserver.Action_VehicleAction{ + VehicleAction: &carserver.VehicleAction{ + VehicleActionMsg: &carserver.VehicleAction_BatchRemovePreconditionSchedulesAction{ + BatchRemovePreconditionSchedulesAction: &carserver.BatchRemovePreconditionSchedulesAction{ + Home: home, + Work: work, + Other: other, + }, + }, + }, + } +} + +// ChangeChargeLimit changes the charge limit. +func ChangeChargeLimit(chargeLimitPercent int32) *carserver.Action_VehicleAction { + return &carserver.Action_VehicleAction{ + VehicleAction: &carserver.VehicleAction{ + VehicleActionMsg: &carserver.VehicleAction_ChargingSetLimitAction{ + ChargingSetLimitAction: &carserver.ChargingSetLimitAction{ + Percent: chargeLimitPercent, + }, + }, + }, + } +} + +// ChargeStart starts charging. +func ChargeStart() *carserver.Action_VehicleAction { + return &carserver.Action_VehicleAction{ + VehicleAction: &carserver.VehicleAction{ + VehicleActionMsg: &carserver.VehicleAction_ChargingStartStopAction{ + ChargingStartStopAction: &carserver.ChargingStartStopAction{ + ChargingAction: &carserver.ChargingStartStopAction_Start{ + Start: &carserver.Void{}, + }, + }, + }, + }, + } +} + +// ChargeStop stops charging. +func ChargeStop() *carserver.Action_VehicleAction { + return &carserver.Action_VehicleAction{ + VehicleAction: &carserver.VehicleAction{ + VehicleActionMsg: &carserver.VehicleAction_ChargingStartStopAction{ + ChargingStartStopAction: &carserver.ChargingStartStopAction{ + ChargingAction: &carserver.ChargingStartStopAction_Stop{ + Stop: &carserver.Void{}, + }, + }, + }, + }, + } +} + +// ChargeMaxRange starts charging in max range mode. +func ChargeMaxRange() *carserver.Action_VehicleAction { + return &carserver.Action_VehicleAction{ + VehicleAction: &carserver.VehicleAction{ + VehicleActionMsg: &carserver.VehicleAction_ChargingStartStopAction{ + ChargingStartStopAction: &carserver.ChargingStartStopAction{ + ChargingAction: &carserver.ChargingStartStopAction_StartMaxRange{ + StartMaxRange: &carserver.Void{}, + }, + }, + }, + }, + } +} + +// SetChargingAmps sets the desired charging amps. +func SetChargingAmps(amps int32) *carserver.Action_VehicleAction { + return &carserver.Action_VehicleAction{ + VehicleAction: &carserver.VehicleAction{ + VehicleActionMsg: &carserver.VehicleAction_SetChargingAmpsAction{ + SetChargingAmpsAction: &carserver.SetChargingAmpsAction{ + ChargingAmps: amps, + }, + }, + }, + } +} + +// ChargeStandardRange starts charging in standard range mode. +func ChargeStandardRange() *carserver.Action_VehicleAction { + return &carserver.Action_VehicleAction{ + VehicleAction: &carserver.VehicleAction{ + VehicleActionMsg: &carserver.VehicleAction_ChargingStartStopAction{ + ChargingStartStopAction: &carserver.ChargingStartStopAction{ + ChargingAction: &carserver.ChargingStartStopAction_StartStandard{ + StartStandard: &carserver.Void{}, + }, + }, + }, + }, + } +} + +// OpenChargePort opens the charge port. +func OpenChargePort() *carserver.Action_VehicleAction { + return &carserver.Action_VehicleAction{ + VehicleAction: &carserver.VehicleAction{ + VehicleActionMsg: &carserver.VehicleAction_ChargePortDoorOpen{ + ChargePortDoorOpen: &carserver.ChargePortDoorOpen{}, + }, + }, + } +} + +// CloseChargePort closes the charge port. +func CloseChargePort() *carserver.Action_VehicleAction { + return &carserver.Action_VehicleAction{ + VehicleAction: &carserver.VehicleAction{ + VehicleActionMsg: &carserver.VehicleAction_ChargePortDoorClose{ + ChargePortDoorClose: &carserver.ChargePortDoorClose{}, + }, + }, + } +} + +// ScheduledDeparture tells the vehicle to charge based on an expected departure time. +// +// Set departAt and offPeakEndTime relative to midnight. +func ScheduleDeparture(departAt, offPeakEndTime time.Duration, preconditioning, offpeak ChargingPolicy) (*carserver.Action_VehicleAction, error) { + if departAt < 0 || departAt > 24*time.Hour { + return nil, fmt.Errorf("invalid departure time") + } + var preconditionProto *carserver.PreconditioningTimes + switch preconditioning { + case ChargingPolicyOff: + case ChargingPolicyAllDays: + preconditionProto = &carserver.PreconditioningTimes{ + Times: &carserver.PreconditioningTimes_AllWeek{ + AllWeek: &carserver.Void{}, + }, + } + case ChargingPolicyWeekdays: + preconditionProto = &carserver.PreconditioningTimes{ + Times: &carserver.PreconditioningTimes_Weekdays{ + Weekdays: &carserver.Void{}, + }, + } + } + + var offPeakProto *carserver.OffPeakChargingTimes + switch offpeak { + case ChargingPolicyOff: + case ChargingPolicyAllDays: + offPeakProto = &carserver.OffPeakChargingTimes{ + Times: &carserver.OffPeakChargingTimes_AllWeek{ + AllWeek: &carserver.Void{}, + }, + } + case ChargingPolicyWeekdays: + offPeakProto = &carserver.OffPeakChargingTimes{ + Times: &carserver.OffPeakChargingTimes_Weekdays{ + Weekdays: &carserver.Void{}, + }, + } + } + + return &carserver.Action_VehicleAction{ + VehicleAction: &carserver.VehicleAction{ + VehicleActionMsg: &carserver.VehicleAction_ScheduledDepartureAction{ + ScheduledDepartureAction: &carserver.ScheduledDepartureAction{ + Enabled: true, + DepartureTime: int32(departAt / time.Minute), + PreconditioningTimes: preconditionProto, + OffPeakChargingTimes: offPeakProto, + OffPeakHoursEndTime: int32(offPeakEndTime / time.Minute), + }, + }, + }, + }, nil +} + +// ScheduleCharging controls scheduled charging. To start charging at 2:00 AM every day, for +// example, set timeAfterMidnight to 2*time.Hour. +// +// See the Owner's Manual for more information. +func ScheduleCharging(enabled bool, timeAfterMidnight time.Duration) *carserver.Action_VehicleAction { + minutesFromMidnight := int32(timeAfterMidnight / time.Minute) + return &carserver.Action_VehicleAction{ + VehicleAction: &carserver.VehicleAction{ + VehicleActionMsg: &carserver.VehicleAction_ScheduledChargingAction{ + ScheduledChargingAction: &carserver.ScheduledChargingAction{ + Enabled: enabled, + ChargingTime: minutesFromMidnight, + }, + }, + }, + } +} + +// ClearScheduledDeparture clears the scheduled departure. +func ClearScheduledDeparture() *carserver.Action_VehicleAction { + return &carserver.Action_VehicleAction{ + VehicleAction: &carserver.VehicleAction{ + VehicleActionMsg: &carserver.VehicleAction_ScheduledDepartureAction{ + ScheduledDepartureAction: &carserver.ScheduledDepartureAction{ + Enabled: false, + }, + }, + }, + } +} + +// GetNearbyChargingSites gets nearby charging sites. +func GetNearbyChargingSites() *carserver.Action_VehicleAction { + return &carserver.Action_VehicleAction{ + VehicleAction: &carserver.VehicleAction{ + VehicleActionMsg: &carserver.VehicleAction_GetNearbyChargingSites{ + GetNearbyChargingSites: &carserver.GetNearbyChargingSites{ + IncludeMetaData: true, + Radius: 200, + Count: 10, + }, + }, + }, + } +} diff --git a/pkg/action/charge_test.go b/pkg/action/charge_test.go new file mode 100644 index 0000000..a6499e8 --- /dev/null +++ b/pkg/action/charge_test.go @@ -0,0 +1,213 @@ +package action_test + +import ( + "time" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + + "github.com/teslamotors/vehicle-command/pkg/action" + "github.com/teslamotors/vehicle-command/pkg/protocol/protobuf/carserver" +) + +var _ = Describe("Charge", func() { + Describe("AddChargeSchedule", func() { + It("returns with correct schedule", func() { + schedule := &carserver.ChargeSchedule{} + action := action.AddChargeSchedule(schedule) + Expect(action).ToNot(BeNil()) + Expect(action.VehicleAction.GetAddChargeScheduleAction()).To(Equal(schedule)) + }) + }) + + Describe("RemoveChargeSchedule", func() { + It("returns with correct ID", func() { + id := uint64(1) + action := action.RemoveChargeSchedule(id) + Expect(action).ToNot(BeNil()) + Expect(action.VehicleAction.GetRemoveChargeScheduleAction()).ToNot(BeNil()) + Expect(action.VehicleAction.GetRemoveChargeScheduleAction().Id).To(Equal(id)) + }) + }) + + Describe("BatchRemoveChargeSchedules", func() { + It("returns with correct locations", func() { + home, work, other := true, true, true + action := action.BatchRemoveChargeSchedules(home, work, other) + Expect(action).ToNot(BeNil()) + Expect(action.VehicleAction.GetBatchRemoveChargeSchedulesAction()).ToNot(BeNil()) + Expect(action.VehicleAction.GetBatchRemoveChargeSchedulesAction().Home).To(Equal(home)) + Expect(action.VehicleAction.GetBatchRemoveChargeSchedulesAction().Work).To(Equal(work)) + Expect(action.VehicleAction.GetBatchRemoveChargeSchedulesAction().Other).To(Equal(other)) + }) + }) + + Describe("AddPreconditionSchedule", func() { + It("returns with correct schedule", func() { + schedule := &carserver.PreconditionSchedule{} + action := action.AddPreconditionSchedule(schedule) + Expect(action).ToNot(BeNil()) + Expect(action.VehicleAction.GetAddPreconditionScheduleAction()).To(Equal(schedule)) + }) + }) + + Describe("RemovePreconditionSchedule", func() { + It("returns with correct ID", func() { + id := uint64(1) + action := action.RemovePreconditionSchedule(id) + Expect(action).ToNot(BeNil()) + Expect(action.VehicleAction.GetRemovePreconditionScheduleAction()).ToNot(BeNil()) + Expect(action.VehicleAction.GetRemovePreconditionScheduleAction().Id).To(Equal(id)) + }) + }) + + Describe("BatchRemovePreconditionSchedules", func() { + It("returns with correct locations", func() { + home, work, other := true, true, true + action := action.BatchRemovePreconditionSchedules(home, work, other) + Expect(action).ToNot(BeNil()) + Expect(action.VehicleAction.GetBatchRemovePreconditionSchedulesAction()).ToNot(BeNil()) + Expect(action.VehicleAction.GetBatchRemovePreconditionSchedulesAction().Home).To(Equal(home)) + Expect(action.VehicleAction.GetBatchRemovePreconditionSchedulesAction().Work).To(Equal(work)) + Expect(action.VehicleAction.GetBatchRemovePreconditionSchedulesAction().Other).To(Equal(other)) + }) + }) + + Describe("ChangeChargeLimit", func() { + It("returns with correct charge limit", func() { + chargeLimitPercent := int32(80) + action := action.ChangeChargeLimit(chargeLimitPercent) + Expect(action).ToNot(BeNil()) + Expect(action.VehicleAction.GetChargingSetLimitAction()).ToNot(BeNil()) + Expect(action.VehicleAction.GetChargingSetLimitAction().Percent).To(Equal(chargeLimitPercent)) + }) + }) + + Describe("ChargeStart", func() { + It("returns start charging", func() { + action := action.ChargeStart() + Expect(action).ToNot(BeNil()) + Expect(action.VehicleAction.GetChargingStartStopAction()).ToNot(BeNil()) + Expect(action.VehicleAction.GetChargingStartStopAction().ChargingAction).To(BeAssignableToTypeOf(&carserver.ChargingStartStopAction_Start{})) + }) + }) + + Describe("ChargeStop", func() { + It("returns stop charging", func() { + action := action.ChargeStop() + Expect(action).ToNot(BeNil()) + Expect(action.VehicleAction.GetChargingStartStopAction()).ToNot(BeNil()) + Expect(action.VehicleAction.GetChargingStartStopAction().ChargingAction).To(BeAssignableToTypeOf(&carserver.ChargingStartStopAction_Stop{})) + }) + }) + + Describe("ChargeMaxRange", func() { + It("returns start charging in max range mode", func() { + action := action.ChargeMaxRange() + Expect(action).ToNot(BeNil()) + Expect(action.VehicleAction.GetChargingStartStopAction()).ToNot(BeNil()) + Expect(action.VehicleAction.GetChargingStartStopAction().GetChargingAction()).To(BeAssignableToTypeOf(&carserver.ChargingStartStopAction_StartMaxRange{})) + }) + }) + + Describe("SetChargingAmps", func() { + It("returns correct amps", func() { + amps := int32(32) + action := action.SetChargingAmps(amps) + Expect(action).ToNot(BeNil()) + Expect(action.VehicleAction.GetSetChargingAmpsAction()).ToNot(BeNil()) + Expect(action.VehicleAction.GetSetChargingAmpsAction().ChargingAmps).To(Equal(amps)) + }) + }) + + Describe("ChargeStandardRange", func() { + It("returns start charging in standard range mode", func() { + action := action.ChargeStandardRange() + Expect(action).ToNot(BeNil()) + Expect(action.VehicleAction.GetChargingStartStopAction()).ToNot(BeNil()) + Expect(action.VehicleAction.GetChargingStartStopAction().GetChargingAction()).To(BeAssignableToTypeOf(&carserver.ChargingStartStopAction_StartStandard{})) + }) + }) + + Describe("OpenChargePort", func() { + It("returns open charge port", func() { + action := action.OpenChargePort() + Expect(action).ToNot(BeNil()) + Expect(action.VehicleAction.GetChargePortDoorOpen()).ToNot(BeNil()) + }) + }) + + Describe("CloseChargePort", func() { + It("returns close charge port", func() { + action := action.CloseChargePort() + Expect(action).ToNot(BeNil()) + Expect(action.VehicleAction.GetChargePortDoorClose()).To(BeAssignableToTypeOf(&carserver.ChargePortDoorClose{})) // Change expected type + }) + }) + + Describe("ScheduleDeparture", func() { + It("returns the correct departure time", func() { + departAt := 2 * time.Hour + offPeakEndTime := 6 * time.Hour + preconditioning := action.ChargingPolicyAllDays + offpeak := action.ChargingPolicyAllDays + action, err := action.ScheduleDeparture(departAt, offPeakEndTime, preconditioning, offpeak) + Expect(err).To(BeNil()) + Expect(action).ToNot(BeNil()) + Expect(action.VehicleAction.GetScheduledDepartureAction()).ToNot(BeNil()) + Expect(action.VehicleAction.GetScheduledDepartureAction().DepartureTime).To(Equal(int32(departAt / time.Minute))) + }) + + It("handles weekdays", func() { + departAt := 2 * time.Hour + offPeakEndTime := 6 * time.Hour + preconditioning := action.ChargingPolicyWeekdays + offpeak := action.ChargingPolicyWeekdays + action, err := action.ScheduleDeparture(departAt, offPeakEndTime, preconditioning, offpeak) + Expect(err).To(BeNil()) + Expect(action).ToNot(BeNil()) + Expect(action.VehicleAction.GetScheduledDepartureAction()).ToNot(BeNil()) + Expect(action.VehicleAction.GetScheduledDepartureAction().DepartureTime).To(Equal(int32(departAt / time.Minute))) + }) + + It("rejects invalid departure times", func() { + departAt := -1 * time.Hour + offPeakEndTime := 6 * time.Hour + preconditioning := action.ChargingPolicyAllDays + offpeak := action.ChargingPolicyAllDays + _, err := action.ScheduleDeparture(departAt, offPeakEndTime, preconditioning, offpeak) + Expect(err).ToNot(BeNil()) + }) + }) + + Describe("ScheduleCharging", func() { + It("returns with correct charging time", func() { + enabled := true + timeAfterMidnight := 2 * time.Hour + action := action.ScheduleCharging(enabled, timeAfterMidnight) + Expect(action).ToNot(BeNil()) + Expect(action.VehicleAction.GetScheduledChargingAction()).ToNot(BeNil()) + Expect(action.VehicleAction.GetScheduledChargingAction().Enabled).To(Equal(enabled)) + Expect(action.VehicleAction.GetScheduledChargingAction().ChargingTime).To(Equal(int32(timeAfterMidnight / time.Minute))) + }) + }) + + Describe("ClearScheduledDeparture", func() { + It("returns scheduled departure", func() { + action := action.ClearScheduledDeparture() + Expect(action).ToNot(BeNil()) + Expect(action.VehicleAction.GetScheduledDepartureAction()).ToNot(BeNil()) + Expect(action.VehicleAction.GetScheduledDepartureAction().Enabled).To(Equal(false)) + }) + }) + Describe("GetNearbyChargingSites", func() { + It("returns nearby charging sites", func() { + action := action.GetNearbyChargingSites() + Expect(action).ToNot(BeNil()) + Expect(action.VehicleAction.GetGetNearbyChargingSites()).ToNot(BeNil()) + Expect(action.VehicleAction.GetGetNearbyChargingSites().GetCount()).To(Equal(int32(10))) + Expect(action.VehicleAction.GetGetNearbyChargingSites().GetRadius()).To(Equal(int32(200))) + Expect(action.VehicleAction.GetGetNearbyChargingSites().GetIncludeMetaData()).To(Equal(true)) + }) + }) +}) diff --git a/pkg/action/climate.go b/pkg/action/climate.go new file mode 100644 index 0000000..b22e755 --- /dev/null +++ b/pkg/action/climate.go @@ -0,0 +1,283 @@ +package action + +import ( + "fmt" + + carserver "github.com/teslamotors/vehicle-command/pkg/protocol/protobuf/carserver" +) + +type SeatPosition int64 + +// Enumerated type for seats. Values with the Back suffix are used for seat heater/cooler commands, +// and refer to the backrest. Backrest heaters are only available on some Model S vehicles. +const ( + SeatUnknown SeatPosition = iota + SeatFrontLeft + SeatFrontRight + SeatSecondRowLeft + SeatSecondRowLeftBack + SeatSecondRowCenter + SeatSecondRowRight + SeatSecondRowRightBack + SeatThirdRowLeft + SeatThirdRowRight +) + +type ClimateKeeperMode = carserver.HvacClimateKeeperAction_ClimateKeeperAction_E + +const ( + ClimateKeeperModeOff = carserver.HvacClimateKeeperAction_ClimateKeeperAction_Off + ClimateKeeperModeOn = carserver.HvacClimateKeeperAction_ClimateKeeperAction_On + ClimateKeeperModeDog = carserver.HvacClimateKeeperAction_ClimateKeeperAction_Dog + ClimateKeeperModeCamp = carserver.HvacClimateKeeperAction_ClimateKeeperAction_Camp +) + +type Level int + +const ( + LevelOff Level = iota + LevelLow + LevelMed + LevelHigh +) + +// SetSeatCooler sets seat cooling level. +func SetSeatCooler(level Level, seat SeatPosition) (*carserver.Action_VehicleAction, error) { + // The protobuf index starts at 0 for unknown, we want to start with 0 for off + seatMap := map[SeatPosition]carserver.HvacSeatCoolerActions_HvacSeatCoolerPosition_E{ + SeatFrontLeft: carserver.HvacSeatCoolerActions_HvacSeatCoolerPosition_FrontLeft, + SeatFrontRight: carserver.HvacSeatCoolerActions_HvacSeatCoolerPosition_FrontRight, + } + protoSeat, ok := seatMap[seat] + if !ok { + return nil, fmt.Errorf("invalid seat position") + } + return &carserver.Action_VehicleAction{ + VehicleAction: &carserver.VehicleAction{ + VehicleActionMsg: &carserver.VehicleAction_HvacSeatCoolerActions{ + HvacSeatCoolerActions: &carserver.HvacSeatCoolerActions{ + HvacSeatCoolerAction: []*carserver.HvacSeatCoolerActions_HvacSeatCoolerAction{ + &carserver.HvacSeatCoolerActions_HvacSeatCoolerAction{ + SeatCoolerLevel: carserver.HvacSeatCoolerActions_HvacSeatCoolerLevel_E(level + 1), + SeatPosition: protoSeat, + }, + }, + }, + }, + }, + }, nil +} + +// ClimateOn turns on the climate control system. +func ClimateOn() *carserver.Action_VehicleAction { + return &carserver.Action_VehicleAction{ + VehicleAction: &carserver.VehicleAction{ + VehicleActionMsg: &carserver.VehicleAction_HvacAutoAction{ + HvacAutoAction: &carserver.HvacAutoAction{ + PowerOn: true, + }, + }, + }, + } +} + +// ClimateOff turns off the climate control system. +func ClimateOff() *carserver.Action_VehicleAction { + return &carserver.Action_VehicleAction{ + VehicleAction: &carserver.VehicleAction{ + VehicleActionMsg: &carserver.VehicleAction_HvacAutoAction{ + HvacAutoAction: &carserver.HvacAutoAction{ + PowerOn: false, + }, + }, + }, + } +} + +// AutoSeatAndClimate turns on or off automatic climate control for the specified seats. +func AutoSeatAndClimate(positions []SeatPosition, enabled bool) *carserver.Action_VehicleAction { + lookup := map[SeatPosition]carserver.AutoSeatClimateAction_AutoSeatPosition_E{ + SeatUnknown: carserver.AutoSeatClimateAction_AutoSeatPosition_Unknown, + SeatFrontLeft: carserver.AutoSeatClimateAction_AutoSeatPosition_FrontLeft, + SeatFrontRight: carserver.AutoSeatClimateAction_AutoSeatPosition_FrontRight, + } + var seats []*carserver.AutoSeatClimateAction_CarSeat + for _, pos := range positions { + if protoPos, ok := lookup[pos]; ok { + seats = append(seats, &carserver.AutoSeatClimateAction_CarSeat{On: enabled, SeatPosition: protoPos}) + } + } + return &carserver.Action_VehicleAction{ + VehicleAction: &carserver.VehicleAction{ + VehicleActionMsg: &carserver.VehicleAction_AutoSeatClimateAction{ + AutoSeatClimateAction: &carserver.AutoSeatClimateAction{ + Carseat: seats, + }, + }, + }, + } +} + +// ChangeClimateTemp sets the desired temperature for the climate control system. +func ChangeClimateTemp(driverCelsius float32, passengerCelsius float32) *carserver.Action_VehicleAction { + return &carserver.Action_VehicleAction{ + VehicleAction: &carserver.VehicleAction{ + VehicleActionMsg: &carserver.VehicleAction_HvacTemperatureAdjustmentAction{ + HvacTemperatureAdjustmentAction: &carserver.HvacTemperatureAdjustmentAction{ + DriverTempCelsius: driverCelsius, + PassengerTempCelsius: passengerCelsius, + Level: &carserver.HvacTemperatureAdjustmentAction_Temperature{ + Type: &carserver.HvacTemperatureAdjustmentAction_Temperature_TEMP_MAX{}, + }, + }, + }, + }, + } +} + +func SetSeatHeater(levels map[SeatPosition]Level) *carserver.Action_VehicleAction { + actions := make([]*carserver.HvacSeatHeaterActions_HvacSeatHeaterAction, 0, len(levels)) + + for position, level := range levels { + action := new(carserver.HvacSeatHeaterActions_HvacSeatHeaterAction) + level.addToSeatHeaterAction(action) + position.addToSeatPositionAction(action) + actions = append(actions, action) + } + + return &carserver.Action_VehicleAction{ + VehicleAction: &carserver.VehicleAction{ + VehicleActionMsg: &carserver.VehicleAction_HvacSeatHeaterActions{ + HvacSeatHeaterActions: &carserver.HvacSeatHeaterActions{ + HvacSeatHeaterAction: actions, + }, + }, + }, + } +} + +// SetSteeringWheelHeater turns on or off the steering wheel heater for supported vehicles. +func SetSteeringWheelHeater(enabled bool) *carserver.Action_VehicleAction { + return &carserver.Action_VehicleAction{ + VehicleAction: &carserver.VehicleAction{ + VehicleActionMsg: &carserver.VehicleAction_HvacSteeringWheelHeaterAction{ + HvacSteeringWheelHeaterAction: &carserver.HvacSteeringWheelHeaterAction{ + PowerOn: enabled, + }, + }, + }, + } +} + +// SetPreconditioningMax turns on or off preconditioning for supported vehicles. +func SetPreconditioningMax(enabled bool, manualOverride bool) *carserver.Action_VehicleAction { + return &carserver.Action_VehicleAction{ + VehicleAction: &carserver.VehicleAction{ + VehicleActionMsg: &carserver.VehicleAction_HvacSetPreconditioningMaxAction{ + HvacSetPreconditioningMaxAction: &carserver.HvacSetPreconditioningMaxAction{ + On: enabled, + ManualOverride: manualOverride, + }, + }, + }, + } +} + +func SetBioweaponDefenseMode(enabled bool, manualOverride bool) *carserver.Action_VehicleAction { + return &carserver.Action_VehicleAction{ + VehicleAction: &carserver.VehicleAction{ + VehicleActionMsg: &carserver.VehicleAction_HvacBioweaponModeAction{ + HvacBioweaponModeAction: &carserver.HvacBioweaponModeAction{ + On: enabled, + ManualOverride: manualOverride, + }, + }, + }, + } +} + +// SetCabinOverheatProtection turns on or off cabin overheat protection for supported vehicles. +func SetCabinOverheatProtection(enabled bool, fanOnly bool) *carserver.Action_VehicleAction { + return &carserver.Action_VehicleAction{ + VehicleAction: &carserver.VehicleAction{ + VehicleActionMsg: &carserver.VehicleAction_SetCabinOverheatProtectionAction{ + SetCabinOverheatProtectionAction: &carserver.SetCabinOverheatProtectionAction{ + On: enabled, + FanOnly: fanOnly, + }, + }, + }, + } +} + +// SetCabinOverheatProtectionTemperature sets the cabin overheat protection activation temperature. +func SetCabinOverheatProtectionTemperature(level Level) *carserver.Action_VehicleAction { + return &carserver.Action_VehicleAction{ + VehicleAction: &carserver.VehicleAction{ + VehicleActionMsg: &carserver.VehicleAction_SetCopTempAction{ + SetCopTempAction: &carserver.SetCopTempAction{ + CopActivationTemp: carserver.ClimateState_CopActivationTemp(level), + }, + }, + }, + } +} + +// SetClimateKeeperMode sets the climate keeper mode. +func SetClimateKeeperMode(mode ClimateKeeperMode, override bool) *carserver.Action_VehicleAction { + return &carserver.Action_VehicleAction{ + VehicleAction: &carserver.VehicleAction{ + VehicleActionMsg: &carserver.VehicleAction_HvacClimateKeeperAction{ + HvacClimateKeeperAction: &carserver.HvacClimateKeeperAction{ + ClimateKeeperAction: mode, + ManualOverride: override, + }, + }, + }, + } +} + +// The seat positions defined in the protobuf sources are each independent Void messages instead of +// enumerated values. The autogenerated protobuf code doesn't export the interface that lets us +// declare or access an interface that includes them collectively. The following functions allow us +// to expose a single enumerated type to library clients. + +func (s SeatPosition) addToSeatPositionAction(action *carserver.HvacSeatHeaterActions_HvacSeatHeaterAction) { + switch s { + case SeatFrontLeft: + action.SeatPosition = &carserver.HvacSeatHeaterActions_HvacSeatHeaterAction_CAR_SEAT_FRONT_LEFT{} + case SeatFrontRight: + action.SeatPosition = &carserver.HvacSeatHeaterActions_HvacSeatHeaterAction_CAR_SEAT_FRONT_RIGHT{} + case SeatSecondRowLeft: + action.SeatPosition = &carserver.HvacSeatHeaterActions_HvacSeatHeaterAction_CAR_SEAT_REAR_LEFT{} + case SeatSecondRowLeftBack: + action.SeatPosition = &carserver.HvacSeatHeaterActions_HvacSeatHeaterAction_CAR_SEAT_REAR_LEFT_BACK{} + case SeatSecondRowCenter: + action.SeatPosition = &carserver.HvacSeatHeaterActions_HvacSeatHeaterAction_CAR_SEAT_REAR_CENTER{} + case SeatSecondRowRight: + action.SeatPosition = &carserver.HvacSeatHeaterActions_HvacSeatHeaterAction_CAR_SEAT_REAR_RIGHT{} + case SeatSecondRowRightBack: + action.SeatPosition = &carserver.HvacSeatHeaterActions_HvacSeatHeaterAction_CAR_SEAT_REAR_RIGHT_BACK{} + case SeatThirdRowLeft: + action.SeatPosition = &carserver.HvacSeatHeaterActions_HvacSeatHeaterAction_CAR_SEAT_THIRD_ROW_LEFT{} + case SeatThirdRowRight: + action.SeatPosition = &carserver.HvacSeatHeaterActions_HvacSeatHeaterAction_CAR_SEAT_THIRD_ROW_RIGHT{} + default: + action.SeatPosition = &carserver.HvacSeatHeaterActions_HvacSeatHeaterAction_CAR_SEAT_UNKNOWN{} + } +} + +func (s Level) addToSeatHeaterAction(action *carserver.HvacSeatHeaterActions_HvacSeatHeaterAction) { + switch s { + case LevelOff: + action.SeatHeaterLevel = &carserver.HvacSeatHeaterActions_HvacSeatHeaterAction_SEAT_HEATER_OFF{} + case LevelLow: + action.SeatHeaterLevel = &carserver.HvacSeatHeaterActions_HvacSeatHeaterAction_SEAT_HEATER_LOW{} + case LevelMed: + action.SeatHeaterLevel = &carserver.HvacSeatHeaterActions_HvacSeatHeaterAction_SEAT_HEATER_MED{} + case LevelHigh: + action.SeatHeaterLevel = &carserver.HvacSeatHeaterActions_HvacSeatHeaterAction_SEAT_HEATER_HIGH{} + default: + action.SeatHeaterLevel = &carserver.HvacSeatHeaterActions_HvacSeatHeaterAction_SEAT_HEATER_UNKNOWN{} + } +} diff --git a/pkg/action/climate_test.go b/pkg/action/climate_test.go new file mode 100644 index 0000000..afd5570 --- /dev/null +++ b/pkg/action/climate_test.go @@ -0,0 +1,118 @@ +package action_test + +import ( + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + + "github.com/teslamotors/vehicle-command/pkg/action" + "github.com/teslamotors/vehicle-command/pkg/protocol/protobuf/carserver" +) + +var _ = Describe("Climate", func() { + Describe("SetPreconditioningMax", func() { + It("returns correct preconditioning settings", func() { + enabled := true + manualOverride := true + action := action.SetPreconditioningMax(enabled, manualOverride) + Expect(action).ToNot(BeNil()) + Expect(action.VehicleAction.GetHvacSetPreconditioningMaxAction()).ToNot(BeNil()) + Expect(action.VehicleAction.GetHvacSetPreconditioningMaxAction().On).To(Equal(enabled)) + Expect(action.VehicleAction.GetHvacSetPreconditioningMaxAction().ManualOverride).To(Equal(manualOverride)) + }) + }) + + Describe("ClimateOn", func() { + It("retucts enable climate control system", func() { + action := action.ClimateOn() + Expect(action).ToNot(BeNil()) + Expect(action.VehicleAction.GetHvacAutoAction()).ToNot(BeNil()) + Expect(action.VehicleAction.GetHvacAutoAction().PowerOn).To(BeTrue()) + }) + }) + + Describe("ClimateOff", func() { + It("returns disable climate control system", func() { + action := action.ClimateOff() + Expect(action).ToNot(BeNil()) + Expect(action.VehicleAction.GetHvacAutoAction()).ToNot(BeNil()) + Expect(action.VehicleAction.GetHvacAutoAction().PowerOn).To(BeFalse()) + }) + }) + + Describe("AutoSeatAndClimate", func() { + It("returns correct auto seat and climate settings", func() { + positions := []action.SeatPosition{action.SeatFrontLeft, action.SeatFrontRight} + enabled := true + action := action.AutoSeatAndClimate(positions, enabled) + Expect(action).ToNot(BeNil()) + Expect(action.VehicleAction.GetAutoSeatClimateAction()).ToNot(BeNil()) + Expect(action.VehicleAction.GetAutoSeatClimateAction().Carseat).To(HaveLen(len(positions))) + for _, seat := range action.VehicleAction.GetAutoSeatClimateAction().Carseat { + Expect(seat.On).To(Equal(enabled)) + } + }) + }) + + Describe("ChangeClimateTemp", func() { + It("returns correct climate temperature settings", func() { + driverCelsius := float32(22.0) + passengerCelsius := float32(22.0) + action := action.ChangeClimateTemp(driverCelsius, passengerCelsius) + Expect(action).ToNot(BeNil()) + Expect(action.VehicleAction.GetHvacTemperatureAdjustmentAction()).ToNot(BeNil()) + Expect(action.VehicleAction.GetHvacTemperatureAdjustmentAction().DriverTempCelsius).To(Equal(driverCelsius)) + Expect(action.VehicleAction.GetHvacTemperatureAdjustmentAction().PassengerTempCelsius).To(Equal(passengerCelsius)) + }) + }) + + Describe("SetSeatHeater", func() { + It("returns correct seat heater settings", func() { + levels := map[action.SeatPosition]action.Level{ + action.SeatFrontLeft: action.LevelHigh, + action.SeatFrontRight: action.LevelHigh, + } + action := action.SetSeatHeater(levels) + Expect(action).ToNot(BeNil()) + Expect(action.VehicleAction.GetHvacSeatHeaterActions()).ToNot(BeNil()) + Expect(action.VehicleAction.GetHvacSeatHeaterActions().HvacSeatHeaterAction).To(HaveLen(len(levels))) + for _, action := range action.VehicleAction.GetHvacSeatHeaterActions().HvacSeatHeaterAction { + Expect(action.SeatHeaterLevel).ToNot(BeNil()) + } + }) + }) + + Describe("SetSteeringWheelHeater", func() { + It("returns change steering wheel heater state", func() { + enabled := true + action := action.SetSteeringWheelHeater(enabled) + Expect(action).ToNot(BeNil()) + Expect(action.VehicleAction.GetHvacSteeringWheelHeaterAction()).ToNot(BeNil()) + Expect(action.VehicleAction.GetHvacSteeringWheelHeaterAction().PowerOn).To(Equal(enabled)) + }) + }) + + Describe("SetCabinOverheatProtectionTemperature", func() { + It("returns correct cabin overheat protection temperature settings", func() { + level := action.LevelHigh + action := action.SetCabinOverheatProtectionTemperature(level) + Expect(action).ToNot(BeNil()) + Expect(action.VehicleAction.GetVehicleActionMsg()).ToNot(BeNil()) + Expect(action.VehicleAction.GetVehicleActionMsg()).To(BeAssignableToTypeOf(&carserver.VehicleAction_SetCopTempAction{})) + Expect(action.VehicleAction.GetVehicleActionMsg().(*carserver.VehicleAction_SetCopTempAction).SetCopTempAction.CopActivationTemp).To(Equal(carserver.ClimateState_CopActivationTemp(level))) + }) + }) + + Describe("SetBioweaponDefenseMode", func() { + It("returns correct bioweapon defense mode settings", func() { + enabled := true + manualOverride := false + action := action.SetBioweaponDefenseMode(enabled, manualOverride) + Expect(action).ToNot(BeNil()) + Expect(action.VehicleAction.GetVehicleActionMsg()).ToNot(BeNil()) + Expect(action.VehicleAction.GetVehicleActionMsg()).To(BeAssignableToTypeOf(&carserver.VehicleAction_HvacBioweaponModeAction{})) + hvacBioweaponModeAction := action.VehicleAction.GetVehicleActionMsg().(*carserver.VehicleAction_HvacBioweaponModeAction).HvacBioweaponModeAction + Expect(hvacBioweaponModeAction.On).To(Equal(enabled)) + Expect(hvacBioweaponModeAction.ManualOverride).To(Equal(manualOverride)) + }) + }) +}) diff --git a/pkg/action/closure.go b/pkg/action/closure.go new file mode 100644 index 0000000..9906bc0 --- /dev/null +++ b/pkg/action/closure.go @@ -0,0 +1,66 @@ +package action + +import "github.com/teslamotors/vehicle-command/pkg/protocol/protobuf/vcsec" + +// Closure represents a part of the vehicle that opens and closes. +type Closure string + +const ( + ClosureTrunk Closure = "trunk" + ClosureFrunk Closure = "frunk" + ClosureTonneau Closure = "tonneau" +) + +// ActuateTrunk opens/closes the trunk state. Note that closing is not available on all vehicles. +func ActuateTrunk() *vcsec.UnsignedMessage { + return buildClosureAction(vcsec.ClosureMoveType_E_CLOSURE_MOVE_TYPE_MOVE, ClosureTrunk) +} + +// OpenTrunk opens the trunk, but note that CloseTrunk is not available on all vehicle types. +func OpenTrunk() *vcsec.UnsignedMessage { + return buildClosureAction(vcsec.ClosureMoveType_E_CLOSURE_MOVE_TYPE_OPEN, ClosureTrunk) +} + +// CloseTrunk is not available on all vehicle types. +func CloseTrunk() *vcsec.UnsignedMessage { + return buildClosureAction(vcsec.ClosureMoveType_E_CLOSURE_MOVE_TYPE_CLOSE, ClosureTrunk) +} + +// OpenFrunk opens the frunk. There is no remote way to close the frunk! +func OpenFrunk() *vcsec.UnsignedMessage { + return buildClosureAction(vcsec.ClosureMoveType_E_CLOSURE_MOVE_TYPE_MOVE, ClosureFrunk) +} + +// OpenTonneau opens a Cybetruck's tonneau. Has no effect on other vehicles. +func OpenTonneau() *vcsec.UnsignedMessage { + return buildClosureAction(vcsec.ClosureMoveType_E_CLOSURE_MOVE_TYPE_OPEN, ClosureTonneau) +} + +// CloseTonneau closes a Cybetruck's tonneau. Has no effect on other vehicles. +func CloseTonneau() *vcsec.UnsignedMessage { + return buildClosureAction(vcsec.ClosureMoveType_E_CLOSURE_MOVE_TYPE_CLOSE, ClosureTonneau) +} + +// StopTonneau tells a Cybetruck to stop moving its tonneau. Has no effect on other vehicles. +func StopTonneau() *vcsec.UnsignedMessage { + return buildClosureAction(vcsec.ClosureMoveType_E_CLOSURE_MOVE_TYPE_STOP, ClosureTonneau) +} + +func buildClosureAction(action vcsec.ClosureMoveType_E, closure Closure) *vcsec.UnsignedMessage { + // Not all actions are meaningful for all closures. Exported methods restrict combinations. + var request vcsec.ClosureMoveRequest + switch closure { + case ClosureTrunk: + request.RearTrunk = action + case ClosureFrunk: + request.FrontTrunk = action + case ClosureTonneau: + request.Tonneau = action + } + + return &vcsec.UnsignedMessage{ + SubMessage: &vcsec.UnsignedMessage_ClosureMoveRequest{ + ClosureMoveRequest: &request, + }, + } +} diff --git a/pkg/action/closure_test.go b/pkg/action/closure_test.go new file mode 100644 index 0000000..ea0166b --- /dev/null +++ b/pkg/action/closure_test.go @@ -0,0 +1,96 @@ +package action_test + +import ( + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + + "github.com/teslamotors/vehicle-command/pkg/action" + "github.com/teslamotors/vehicle-command/pkg/protocol/protobuf/vcsec" +) + +var _ = Describe("Closure", func() { + Describe("ActuateTrunk", func() { + It("returns with correct trunk action", func() { + action := action.ActuateTrunk() + Expect(action).ToNot(BeNil()) + Expect(action.SubMessage).ToNot(BeNil()) + Expect(action.SubMessage).To(BeAssignableToTypeOf(&vcsec.UnsignedMessage_ClosureMoveRequest{})) + moveRequest := action.SubMessage.(*vcsec.UnsignedMessage_ClosureMoveRequest).ClosureMoveRequest + Expect(moveRequest).ToNot(BeNil()) + Expect(moveRequest.RearTrunk).To(Equal(vcsec.ClosureMoveType_E_CLOSURE_MOVE_TYPE_MOVE)) + }) + }) + + Describe("OpenTrunk", func() { + It("returns with correct trunk action", func() { + action := action.OpenTrunk() + Expect(action).ToNot(BeNil()) + Expect(action.SubMessage).ToNot(BeNil()) + Expect(action.SubMessage).To(BeAssignableToTypeOf(&vcsec.UnsignedMessage_ClosureMoveRequest{})) + moveRequest := action.SubMessage.(*vcsec.UnsignedMessage_ClosureMoveRequest).ClosureMoveRequest + Expect(moveRequest).ToNot(BeNil()) + Expect(action.SubMessage.(*vcsec.UnsignedMessage_ClosureMoveRequest).ClosureMoveRequest).ToNot(BeNil()) + Expect(action.SubMessage.(*vcsec.UnsignedMessage_ClosureMoveRequest).ClosureMoveRequest.RearTrunk).To(Equal(vcsec.ClosureMoveType_E_CLOSURE_MOVE_TYPE_OPEN)) + }) + }) + + Describe("CloseTrunk", func() { + It("returns with correct trunk action", func() { + action := action.CloseTrunk() + Expect(action).ToNot(BeNil()) + Expect(action.SubMessage).ToNot(BeNil()) + Expect(action.SubMessage).To(BeAssignableToTypeOf(&vcsec.UnsignedMessage_ClosureMoveRequest{})) + moveRequest := action.SubMessage.(*vcsec.UnsignedMessage_ClosureMoveRequest).ClosureMoveRequest + Expect(moveRequest).ToNot(BeNil()) + Expect(moveRequest.RearTrunk).To(Equal(vcsec.ClosureMoveType_E_CLOSURE_MOVE_TYPE_CLOSE)) + }) + }) + + Describe("OpenFrunk", func() { + It("returns with correct frunk action", func() { + action := action.OpenFrunk() + Expect(action).ToNot(BeNil()) + Expect(action.SubMessage).ToNot(BeNil()) + Expect(action.SubMessage).To(BeAssignableToTypeOf(&vcsec.UnsignedMessage_ClosureMoveRequest{})) + moveRequest := action.SubMessage.(*vcsec.UnsignedMessage_ClosureMoveRequest).ClosureMoveRequest + Expect(moveRequest).ToNot(BeNil()) + Expect(moveRequest.FrontTrunk).To(Equal(vcsec.ClosureMoveType_E_CLOSURE_MOVE_TYPE_MOVE)) + }) + }) + + Describe("OpenTonneau", func() { + It("returns with correct tonneau action", func() { + action := action.OpenTonneau() + Expect(action).ToNot(BeNil()) + Expect(action.SubMessage).ToNot(BeNil()) + Expect(action.SubMessage).To(BeAssignableToTypeOf(&vcsec.UnsignedMessage_ClosureMoveRequest{})) + moveRequest := action.SubMessage.(*vcsec.UnsignedMessage_ClosureMoveRequest).ClosureMoveRequest + Expect(moveRequest).ToNot(BeNil()) + Expect(moveRequest.Tonneau).To(Equal(vcsec.ClosureMoveType_E_CLOSURE_MOVE_TYPE_OPEN)) + }) + }) + + Describe("CloseTonneau", func() { + It("returns with correct tonneau action", func() { + action := action.CloseTonneau() + Expect(action).ToNot(BeNil()) + Expect(action.SubMessage).ToNot(BeNil()) + Expect(action.SubMessage).To(BeAssignableToTypeOf(&vcsec.UnsignedMessage_ClosureMoveRequest{})) + moveRequest := action.SubMessage.(*vcsec.UnsignedMessage_ClosureMoveRequest).ClosureMoveRequest + Expect(moveRequest).ToNot(BeNil()) + Expect(moveRequest.Tonneau).To(Equal(vcsec.ClosureMoveType_E_CLOSURE_MOVE_TYPE_CLOSE)) + }) + }) + + Describe("StopTonneau", func() { + It("returns with correct tonneau action", func() { + action := action.StopTonneau() + Expect(action).ToNot(BeNil()) + Expect(action.SubMessage).ToNot(BeNil()) + Expect(action.SubMessage).To(BeAssignableToTypeOf(&vcsec.UnsignedMessage_ClosureMoveRequest{})) + moveRequest := action.SubMessage.(*vcsec.UnsignedMessage_ClosureMoveRequest).ClosureMoveRequest + Expect(moveRequest).ToNot(BeNil()) + Expect(moveRequest.Tonneau).To(Equal(vcsec.ClosureMoveType_E_CLOSURE_MOVE_TYPE_STOP)) + }) + }) +}) diff --git a/pkg/action/doc.go b/pkg/action/doc.go new file mode 100644 index 0000000..5ff1de9 --- /dev/null +++ b/pkg/action/doc.go @@ -0,0 +1,2 @@ +// Package action contains functions for creating actions to send to vehicles. +package action diff --git a/pkg/action/exterior.go b/pkg/action/exterior.go new file mode 100644 index 0000000..6942394 --- /dev/null +++ b/pkg/action/exterior.go @@ -0,0 +1,70 @@ +package action + +import "github.com/teslamotors/vehicle-command/pkg/protocol/protobuf/carserver" + +// HonkHorn honks the vehicle's horn. +func HonkHorn() *carserver.Action_VehicleAction { + return &carserver.Action_VehicleAction{ + VehicleAction: &carserver.VehicleAction{ + VehicleActionMsg: &carserver.VehicleAction_VehicleControlHonkHornAction{ + VehicleControlHonkHornAction: &carserver.VehicleControlHonkHornAction{}, + }, + }, + } +} + +// FlashLights flashes the vehicle's exterior lights. +func FlashLights() *carserver.Action_VehicleAction { + return &carserver.Action_VehicleAction{ + VehicleAction: &carserver.VehicleAction{ + VehicleActionMsg: &carserver.VehicleAction_VehicleControlFlashLightsAction{ + VehicleControlFlashLightsAction: &carserver.VehicleControlFlashLightsAction{}, + }, + }, + } +} + +// ChangeSunroofState changes the state of the sunroof on supported vehicles. +func ChangeSunroofState(sunroofLevel int32) *carserver.Action_VehicleAction { + return &carserver.Action_VehicleAction{ + VehicleAction: &carserver.VehicleAction{ + VehicleActionMsg: &carserver.VehicleAction_VehicleControlSunroofOpenCloseAction{ + VehicleControlSunroofOpenCloseAction: &carserver.VehicleControlSunroofOpenCloseAction{ + SunroofLevel: &carserver.VehicleControlSunroofOpenCloseAction_AbsoluteLevel{ + AbsoluteLevel: sunroofLevel, + }, + }, + }, + }, + } +} + +// CloseWindows closes the windows on the vehicle. +func CloseWindows() *carserver.Action_VehicleAction { + return &carserver.Action_VehicleAction{ + VehicleAction: &carserver.VehicleAction{ + VehicleActionMsg: &carserver.VehicleAction_VehicleControlWindowAction{ + VehicleControlWindowAction: &carserver.VehicleControlWindowAction{ + Action: &carserver.VehicleControlWindowAction_Close{ + Close: &carserver.Void{}, + }, + }, + }, + }, + } +} + +// VentWindows cracks the windows on the vehicle. +func VentWindows() *carserver.Action_VehicleAction { + return &carserver.Action_VehicleAction{ + VehicleAction: &carserver.VehicleAction{ + VehicleActionMsg: &carserver.VehicleAction_VehicleControlWindowAction{ + VehicleControlWindowAction: &carserver.VehicleControlWindowAction{ + Action: &carserver.VehicleControlWindowAction_Vent{ + Vent: &carserver.Void{}, + }, + }, + }, + }, + } +} diff --git a/pkg/action/exterior_test.go b/pkg/action/exterior_test.go new file mode 100644 index 0000000..4b88212 --- /dev/null +++ b/pkg/action/exterior_test.go @@ -0,0 +1,104 @@ +package action_test + +import ( + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + + "github.com/teslamotors/vehicle-command/pkg/action" + "github.com/teslamotors/vehicle-command/pkg/protocol/protobuf/carserver" +) + +var _ = Describe("Actions", func() { + Describe("HonkHorn", func() { + It("returns honk action", func() { + action := action.HonkHorn() + Expect(action).ToNot(BeNil()) + Expect(action.VehicleAction.GetVehicleControlHonkHornAction()).ToNot(BeNil()) + }) + }) + + Describe("FlashLights", func() { + It("returns flash lights action", func() { + action := action.FlashLights() + Expect(action).ToNot(BeNil()) + Expect(action.VehicleAction.GetVehicleControlFlashLightsAction()).ToNot(BeNil()) + }) + }) + + Describe("ChangeSunroofState", func() { + It("returns correct sunroof level", func() { + sunroofLevel := int32(50) + action := action.ChangeSunroofState(sunroofLevel) + Expect(action).ToNot(BeNil()) + Expect(action.VehicleAction.GetVehicleControlSunroofOpenCloseAction()).ToNot(BeNil()) + Expect(action.VehicleAction.GetVehicleControlSunroofOpenCloseAction().GetSunroofLevel()).To(BeAssignableToTypeOf(&carserver.VehicleControlSunroofOpenCloseAction_AbsoluteLevel{})) + Expect(action.VehicleAction.GetVehicleControlSunroofOpenCloseAction().GetSunroofLevel().(*carserver.VehicleControlSunroofOpenCloseAction_AbsoluteLevel).AbsoluteLevel).To(Equal(sunroofLevel)) + }) + }) + + Describe("CloseWindows", func() { + It("returns close windows", func() { + action := action.CloseWindows() + Expect(action).ToNot(BeNil()) + Expect(action.VehicleAction.GetVehicleControlWindowAction().GetClose()).ToNot(BeNil()) + }) + }) + + Describe("VentWindows", func() { + It("returns vent windows", func() { + action := action.VentWindows() + Expect(action).ToNot(BeNil()) + Expect(action.VehicleAction.GetVehicleControlWindowAction().GetVent()).ToNot(BeNil()) + }) + }) + + Describe("SetClimateKeeperMode", func() { + It("returns correct climate keeper mode action", func() { + override := true + action := action.SetClimateKeeperMode(action.ClimateKeeperModeCamp, override) + Expect(action).ToNot(BeNil()) + Expect(action.VehicleAction.GetVehicleActionMsg()).ToNot(BeNil()) + Expect(action.VehicleAction.GetVehicleActionMsg()).To(BeAssignableToTypeOf(&carserver.VehicleAction_HvacClimateKeeperAction{})) + hvacClimateKeeperAction := action.VehicleAction.GetVehicleActionMsg().(*carserver.VehicleAction_HvacClimateKeeperAction) + Expect(hvacClimateKeeperAction.HvacClimateKeeperAction.GetManualOverride()).To(Equal(override)) + Expect(hvacClimateKeeperAction.HvacClimateKeeperAction.GetClimateKeeperAction()).To(Equal(carserver.HvacClimateKeeperAction_ClimateKeeperAction_Camp)) + }) + }) + + Describe("SetCabinOverheatProtection", func() { + It("returns set cabin overheat protection action", func() { + enabled, fanOnly := true, true + action := action.SetCabinOverheatProtection(enabled, fanOnly) + Expect(action).ToNot(BeNil()) + Expect(action.VehicleAction.GetVehicleActionMsg()).ToNot(BeNil()) + Expect(action.VehicleAction.GetVehicleActionMsg()).To(BeAssignableToTypeOf(&carserver.VehicleAction_SetCabinOverheatProtectionAction{})) + setCabinOverheatProtectionAction := action.VehicleAction.GetVehicleActionMsg().(*carserver.VehicleAction_SetCabinOverheatProtectionAction) + Expect(setCabinOverheatProtectionAction.SetCabinOverheatProtectionAction.On).To(Equal(enabled)) + Expect(setCabinOverheatProtectionAction.SetCabinOverheatProtectionAction.FanOnly).To(Equal(fanOnly)) + }) + }) + + Describe("SetSeatCooler", func() { + It("returns set seat cooler action", func() { + seat := action.SeatFrontLeft + level := action.LevelLow + action, err := action.SetSeatCooler(level, seat) + Expect(err).ToNot(HaveOccurred()) + Expect(action).ToNot(BeNil()) + Expect(action.VehicleAction.GetVehicleActionMsg()).ToNot(BeNil()) + Expect(action.VehicleAction.GetVehicleActionMsg()).To(BeAssignableToTypeOf(&carserver.VehicleAction_HvacSeatCoolerActions{})) + hvacSeatCoolerActions := action.VehicleAction.GetVehicleActionMsg().(*carserver.VehicleAction_HvacSeatCoolerActions) + actions := hvacSeatCoolerActions.HvacSeatCoolerActions.GetHvacSeatCoolerAction() + Expect(actions).To(HaveLen(1)) + Expect(actions[0].GetSeatPosition()).To(Equal(carserver.HvacSeatCoolerActions_HvacSeatCoolerPosition_FrontLeft)) + Expect(actions[0].GetSeatCoolerLevel()).To(Equal(carserver.HvacSeatCoolerActions_HvacSeatCoolerLevel_Low)) + }) + + It("returns error on invalid seat cooler level", func() { + seat := action.SeatSecondRowCenter + level := action.LevelLow + _, err := action.SetSeatCooler(level, seat) + Expect(err).To(HaveOccurred()) + }) + }) +}) diff --git a/pkg/action/infotainment.go b/pkg/action/infotainment.go new file mode 100644 index 0000000..8958c06 --- /dev/null +++ b/pkg/action/infotainment.go @@ -0,0 +1,93 @@ +package action + +import ( + "fmt" + "time" + + carserver "github.com/teslamotors/vehicle-command/pkg/protocol/protobuf/carserver" +) + +// Ping sends an authenticated "no-op" command to the vehicle. +// If the method returns an non-nil error, then the vehicle is online and recognizes the client's +// public key. +// +// The error is a [protocol.RoutableMessageError] then the vehicle is online, but rejected the command +// for some other reason (for example, it may not recognize the client's public key or may have +// mobile access disabled). +func Ping() *carserver.Action_VehicleAction { + return &carserver.Action_VehicleAction{ + VehicleAction: &carserver.VehicleAction{ + VehicleActionMsg: &carserver.VehicleAction_Ping{ + Ping: &carserver.Ping{ + PingId: 1, // Responses are disambiguated on the protocol later + }, + }, + }, + } +} + +// SetVolume to a value between 0 and 10. +func SetVolume(volume float32) (*carserver.Action_VehicleAction, error) { + if volume < 0 || volume > 10 { + return nil, fmt.Errorf("invalid volume (should be in [0, 10])") + } + return &carserver.Action_VehicleAction{ + VehicleAction: &carserver.VehicleAction{ + VehicleActionMsg: &carserver.VehicleAction_MediaUpdateVolume{ + MediaUpdateVolume: &carserver.MediaUpdateVolume{ + MediaVolume: &carserver.MediaUpdateVolume_VolumeAbsoluteFloat{ + VolumeAbsoluteFloat: volume, + }, + }, + }, + }, + }, nil +} + +// ToggleMediaPlayback toggles media pause/play state. +func ToggleMediaPlayback() *carserver.Action_VehicleAction { + return &carserver.Action_VehicleAction{ + VehicleAction: &carserver.VehicleAction{ + VehicleActionMsg: &carserver.VehicleAction_MediaPlayAction{ + MediaPlayAction: &carserver.MediaPlayAction{}, + }, + }, + } +} + +// ScheduleSoftwareUpdate schedules a software update to start after a delay. +func ScheduleSoftwareUpdate(delay time.Duration) *carserver.Action_VehicleAction { + return &carserver.Action_VehicleAction{ + VehicleAction: &carserver.VehicleAction{ + VehicleActionMsg: &carserver.VehicleAction_VehicleControlScheduleSoftwareUpdateAction{ + VehicleControlScheduleSoftwareUpdateAction: &carserver.VehicleControlScheduleSoftwareUpdateAction{ + OffsetSec: int32(delay.Seconds()), + }, + }, + }, + } +} + +// CancelSoftwareUpdate cancels a pending software update. +func CancelSoftwareUpdate() *carserver.Action_VehicleAction { + return &carserver.Action_VehicleAction{ + VehicleAction: &carserver.VehicleAction{ + VehicleActionMsg: &carserver.VehicleAction_VehicleControlCancelSoftwareUpdateAction{ + VehicleControlCancelSoftwareUpdateAction: &carserver.VehicleControlCancelSoftwareUpdateAction{}, + }, + }, + } +} + +// SetVehicleName sets the vehicle name. +func SetVehicleName(name string) *carserver.Action_VehicleAction { + return &carserver.Action_VehicleAction{ + VehicleAction: &carserver.VehicleAction{ + VehicleActionMsg: &carserver.VehicleAction_SetVehicleNameAction{ + SetVehicleNameAction: &carserver.SetVehicleNameAction{ + VehicleName: name, + }, + }, + }, + } +} diff --git a/pkg/action/infotainment_test.go b/pkg/action/infotainment_test.go new file mode 100644 index 0000000..b246852 --- /dev/null +++ b/pkg/action/infotainment_test.go @@ -0,0 +1,83 @@ +package action_test + +import ( + "time" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + + "github.com/teslamotors/vehicle-command/pkg/action" + "github.com/teslamotors/vehicle-command/pkg/protocol/protobuf/carserver" +) + +var _ = Describe("Infotainment", func() { + Describe("Ping", func() { + It("returns ping action", func() { + action := action.Ping() + Expect(action).ToNot(BeNil()) + Expect(action.VehicleAction.GetVehicleActionMsg()).ToNot(BeNil()) + Expect(action.VehicleAction.GetVehicleActionMsg()).To(BeAssignableToTypeOf(&carserver.VehicleAction_Ping{})) + Expect(action.VehicleAction.GetVehicleActionMsg().(*carserver.VehicleAction_Ping).Ping.PingId).To(Equal(int32(1))) + }) + }) + + Describe("SetVolume", func() { + It("returns set volume action", func() { + volume := float32(5) + action, err := action.SetVolume(volume) + Expect(err).To(BeNil()) + Expect(action).ToNot(BeNil()) + Expect(action.VehicleAction.GetVehicleActionMsg()).ToNot(BeNil()) + Expect(action.VehicleAction.GetVehicleActionMsg()).To(BeAssignableToTypeOf(&carserver.VehicleAction_MediaUpdateVolume{})) + mediaUpdateVolume := action.VehicleAction.GetVehicleActionMsg().(*carserver.VehicleAction_MediaUpdateVolume).MediaUpdateVolume + Expect(mediaUpdateVolume.GetVolumeAbsoluteFloat()).To(Equal(volume)) + }) + + It("returns error on invalid volume", func() { + _, err := action.SetVolume(-1) + Expect(err).ToNot(BeNil()) + }) + }) + + Describe("ToggleMediaPlayback", func() { + It("returns toggle media playback action", func() { + action := action.ToggleMediaPlayback() + Expect(action).ToNot(BeNil()) + Expect(action.VehicleAction.GetVehicleActionMsg()).ToNot(BeNil()) + Expect(action.VehicleAction.GetVehicleActionMsg()).To(BeAssignableToTypeOf(&carserver.VehicleAction_MediaPlayAction{})) + Expect(action.VehicleAction.GetVehicleActionMsg().(*carserver.VehicleAction_MediaPlayAction).MediaPlayAction).ToNot(BeNil()) + }) + }) + + Describe("ScheduleSoftwareUpdate", func() { + It("returns schedule software update action with correct delay", func() { + delay := 1 * time.Hour + action := action.ScheduleSoftwareUpdate(delay) + Expect(action).ToNot(BeNil()) + Expect(action.VehicleAction.GetVehicleActionMsg()).ToNot(BeNil()) + Expect(action.VehicleAction.GetVehicleActionMsg()).To(BeAssignableToTypeOf(&carserver.VehicleAction_VehicleControlScheduleSoftwareUpdateAction{})) + Expect(action.VehicleAction.GetVehicleActionMsg().(*carserver.VehicleAction_VehicleControlScheduleSoftwareUpdateAction).VehicleControlScheduleSoftwareUpdateAction.OffsetSec).To(Equal(int32(delay.Seconds()))) + }) + }) + + Describe("CancelSoftwareUpdate", func() { + It("returns cancel software update action", func() { + action := action.CancelSoftwareUpdate() + Expect(action).ToNot(BeNil()) + Expect(action.VehicleAction.GetVehicleActionMsg()).ToNot(BeNil()) + Expect(action.VehicleAction.GetVehicleActionMsg()).To(BeAssignableToTypeOf(&carserver.VehicleAction_VehicleControlCancelSoftwareUpdateAction{})) + Expect(action.VehicleAction.GetVehicleActionMsg().(*carserver.VehicleAction_VehicleControlCancelSoftwareUpdateAction).VehicleControlCancelSoftwareUpdateAction).ToNot(BeNil()) + }) + }) + + Describe("SetVehicleName", func() { + It("returns set vehicle name action", func() { + name := "Test Vehicle" + action := action.SetVehicleName(name) + Expect(action).ToNot(BeNil()) + Expect(action.VehicleAction.GetVehicleActionMsg()).ToNot(BeNil()) + Expect(action.VehicleAction.GetVehicleActionMsg()).To(BeAssignableToTypeOf(&carserver.VehicleAction_SetVehicleNameAction{})) + Expect(action.VehicleAction.GetVehicleActionMsg().(*carserver.VehicleAction_SetVehicleNameAction).SetVehicleNameAction.VehicleName).To(Equal(name)) + }) + }) +}) diff --git a/pkg/action/rke.go b/pkg/action/rke.go new file mode 100644 index 0000000..238792d --- /dev/null +++ b/pkg/action/rke.go @@ -0,0 +1,39 @@ +package action + +import "github.com/teslamotors/vehicle-command/pkg/protocol/protobuf/vcsec" + +// AutoSecureVehicle secures the vehicle by locking and closing doors and windows. +func AutoSecureVehicle() *vcsec.UnsignedMessage { + return buildRKEAction(vcsec.RKEAction_E_RKE_ACTION_AUTO_SECURE_VEHICLE) +} + +// Lock locks the vehicle. +func Lock() *vcsec.UnsignedMessage { + return buildRKEAction(vcsec.RKEAction_E_RKE_ACTION_LOCK) +} + +// Unlock unlocks the vehicle. +func Unlock() *vcsec.UnsignedMessage { + return buildRKEAction(vcsec.RKEAction_E_RKE_ACTION_UNLOCK) +} + +// RemoteDrive allows the vehicle to be driven. +func RemoteDrive() *vcsec.UnsignedMessage { + return buildRKEAction(vcsec.RKEAction_E_RKE_ACTION_REMOTE_DRIVE) +} + +// WakeUp wakes up the vehicle. +func WakeUp() *vcsec.UnsignedMessage { + return buildRKEAction(vcsec.RKEAction_E_RKE_ACTION_WAKE_VEHICLE) +} + +// buildRKEAction builds an RKE action command to be sent to the vehicle. +// (RKE originally referred to "Remote Keyless Entry" but now refers more +// generally to commands that can be sent by a keyfob). +func buildRKEAction(action vcsec.RKEAction_E) *vcsec.UnsignedMessage { + return &vcsec.UnsignedMessage{ + SubMessage: &vcsec.UnsignedMessage_RKEAction{ + RKEAction: action, + }, + } +} diff --git a/pkg/action/rke_test.go b/pkg/action/rke_test.go new file mode 100644 index 0000000..c514523 --- /dev/null +++ b/pkg/action/rke_test.go @@ -0,0 +1,51 @@ +package action_test + +import ( + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + + "github.com/teslamotors/vehicle-command/pkg/action" + "github.com/teslamotors/vehicle-command/pkg/protocol/protobuf/vcsec" +) + +var _ = Describe("RKE Actions", func() { + Describe("AutoSecureVehicle", func() { + It("returns auto secure vehicle action", func() { + action := action.AutoSecureVehicle() + Expect(action).ToNot(BeNil()) + Expect(action.SubMessage).ToNot(BeNil()) + Expect(action.SubMessage).To(BeAssignableToTypeOf(&vcsec.UnsignedMessage_RKEAction{})) + Expect(action.SubMessage.(*vcsec.UnsignedMessage_RKEAction).RKEAction).To(Equal(vcsec.RKEAction_E_RKE_ACTION_AUTO_SECURE_VEHICLE)) + }) + }) + + Describe("Lock", func() { + It("returns lock vehicle action", func() { + action := action.Lock() + Expect(action).ToNot(BeNil()) + Expect(action.SubMessage).ToNot(BeNil()) + Expect(action.SubMessage).To(BeAssignableToTypeOf(&vcsec.UnsignedMessage_RKEAction{})) + Expect(action.SubMessage.(*vcsec.UnsignedMessage_RKEAction).RKEAction).To(Equal(vcsec.RKEAction_E_RKE_ACTION_LOCK)) + }) + }) + + Describe("Unlock", func() { + It("returns unlock action", func() { + action := action.Unlock() + Expect(action).ToNot(BeNil()) + Expect(action.SubMessage).ToNot(BeNil()) + Expect(action.SubMessage).To(BeAssignableToTypeOf(&vcsec.UnsignedMessage_RKEAction{})) + Expect(action.SubMessage.(*vcsec.UnsignedMessage_RKEAction).RKEAction).To(Equal(vcsec.RKEAction_E_RKE_ACTION_UNLOCK)) + }) + }) + + Describe("RemoteDrive", func() { + It("returns remote drive action", func() { + action := action.RemoteDrive() + Expect(action).ToNot(BeNil()) + Expect(action.SubMessage).ToNot(BeNil()) + Expect(action.SubMessage).To(BeAssignableToTypeOf(&vcsec.UnsignedMessage_RKEAction{})) + Expect(action.SubMessage.(*vcsec.UnsignedMessage_RKEAction).RKEAction).To(Equal(vcsec.RKEAction_E_RKE_ACTION_REMOTE_DRIVE)) + }) + }) +}) diff --git a/pkg/action/security.go b/pkg/action/security.go new file mode 100644 index 0000000..66cdfda --- /dev/null +++ b/pkg/action/security.go @@ -0,0 +1,174 @@ +package action + +import ( + carserver "github.com/teslamotors/vehicle-command/pkg/protocol/protobuf/carserver" +) + +// SetValetMode sets the valet mode on or off. If enabling, sets the password. +func SetValetMode(on bool, valetPassword string) *carserver.Action_VehicleAction { + return &carserver.Action_VehicleAction{ + VehicleAction: &carserver.VehicleAction{ + VehicleActionMsg: &carserver.VehicleAction_VehicleControlSetValetModeAction{ + VehicleControlSetValetModeAction: &carserver.VehicleControlSetValetModeAction{ + On: on, + Password: valetPassword, + }, + }, + }, + } +} + +// ResetValetPin resets the valet pin. +func ResetValetPin() *carserver.Action_VehicleAction { + return &carserver.Action_VehicleAction{ + VehicleAction: &carserver.VehicleAction{ + VehicleActionMsg: &carserver.VehicleAction_VehicleControlResetValetPinAction{ + VehicleControlResetValetPinAction: &carserver.VehicleControlResetValetPinAction{}, + }, + }, + } +} + +// ResetPIN clears the saved PIN. You must disable PIN to drive before clearing the PIN. This allows +// setting a new PIN using SetPINToDrive. +func ResetPIN() *carserver.Action_VehicleAction { + return &carserver.Action_VehicleAction{ + VehicleAction: &carserver.VehicleAction{ + VehicleActionMsg: &carserver.VehicleAction_VehicleControlResetPinToDriveAction{ + VehicleControlResetPinToDriveAction: &carserver.VehicleControlResetPinToDriveAction{}, + }, + }, + } +} + +// ActivateSpeedLimit limits the maximum speed of the vehicle. The actual speed limit is set +// using SpeedLimitSetLimitMPH. +func ActivateSpeedLimit(speedLimitPin string) *carserver.Action_VehicleAction { + return &carserver.Action_VehicleAction{ + VehicleAction: &carserver.VehicleAction{ + VehicleActionMsg: &carserver.VehicleAction_DrivingSpeedLimitAction{ + DrivingSpeedLimitAction: &carserver.DrivingSpeedLimitAction{ + Activate: true, + Pin: speedLimitPin, + }, + }, + }, + } +} + +// DeactivateSpeedLimit removes a maximum speed restriction from the vehicle. +func DeactivateSpeedLimit(speedLimitPin string) *carserver.Action_VehicleAction { + return &carserver.Action_VehicleAction{ + VehicleAction: &carserver.VehicleAction{ + VehicleActionMsg: &carserver.VehicleAction_DrivingSpeedLimitAction{ + DrivingSpeedLimitAction: &carserver.DrivingSpeedLimitAction{ + Activate: false, + Pin: speedLimitPin, + }, + }, + }, + } +} + +// SpeedLimitSetLimitMPH sets the speed limit in MPH. +func SpeedLimitSetLimitMPH(speedLimitMPH float64) *carserver.Action_VehicleAction { + return &carserver.Action_VehicleAction{ + VehicleAction: &carserver.VehicleAction{ + VehicleActionMsg: &carserver.VehicleAction_DrivingSetSpeedLimitAction{ + DrivingSetSpeedLimitAction: &carserver.DrivingSetSpeedLimitAction{ + LimitMph: speedLimitMPH, + }, + }, + }, + } +} + +// ClearSpeedLimitPIN clears the speed limit pin. +func ClearSpeedLimitPIN(speedLimitPin string) *carserver.Action_VehicleAction { + return &carserver.Action_VehicleAction{ + VehicleAction: &carserver.VehicleAction{ + VehicleActionMsg: &carserver.VehicleAction_DrivingClearSpeedLimitPinAction{ + DrivingClearSpeedLimitPinAction: &carserver.DrivingClearSpeedLimitPinAction{ + Pin: speedLimitPin, + }, + }, + }, + } +} + +// SetSentryMode enables or disables sentry mode. +func SetSentryMode(state bool) *carserver.Action_VehicleAction { + return &carserver.Action_VehicleAction{ + VehicleAction: &carserver.VehicleAction{ + VehicleActionMsg: &carserver.VehicleAction_VehicleControlSetSentryModeAction{ + VehicleControlSetSentryModeAction: &carserver.VehicleControlSetSentryModeAction{ + On: state, + }, + }, + }, + } +} + +// SetGuestMode enables or disables the vehicle's guest mode. +// +// We recommend users avoid this command unless they are managing a fleet of vehicles and understand +// the implications of enabling the mode. See official API documentation at +// https://developer.tesla.com/docs/fleet-api/endpoints/vehicle-commands#guest-mode +func SetGuestMode(enabled bool) *carserver.Action_VehicleAction { + return &carserver.Action_VehicleAction{ + VehicleAction: &carserver.VehicleAction{ + VehicleActionMsg: &carserver.VehicleAction_GuestModeAction{ + GuestModeAction: &carserver.VehicleState_GuestMode{ + GuestModeActive: enabled, + }, + }, + }, + } +} + +// SetPINToDrive controls whether the PIN to Drive feature is enabled or not. It is also used to set +// the PIN. + +// Once a PIN is set, the vehicle remembers its value even when PIN to Drive is disabled and +// discards any new PIN provided using this method. To change an existing PIN, first call +// v.ResetPIN. +// +// Must be used through Fleet API. +func SetPINToDrive(enabled bool, pin string) *carserver.Action_VehicleAction { + return &carserver.Action_VehicleAction{ + VehicleAction: &carserver.VehicleAction{ + VehicleActionMsg: &carserver.VehicleAction_VehicleControlSetPinToDriveAction{ + VehicleControlSetPinToDriveAction: &carserver.VehicleControlSetPinToDriveAction{ + On: enabled, + Password: pin, + }, + }, + }, + } +} + +// TriggerHomelink triggers homelink at a given coordinate. +func TriggerHomelink(latitude float32, longitude float32) *carserver.Action_VehicleAction { + return &carserver.Action_VehicleAction{ + VehicleAction: &carserver.VehicleAction{ + VehicleActionMsg: &carserver.VehicleAction_VehicleControlTriggerHomelinkAction{ + VehicleControlTriggerHomelinkAction: &carserver.VehicleControlTriggerHomelinkAction{ + Location: &carserver.LatLong{ + Latitude: latitude, + Longitude: longitude, + }, + }, + }, + }, + } +} + +// EraseGuestData erases user data created while in Guest Mode. This command has no effect unless +// the vehicle is currently in Guest Mode. +func EraseGuestData() *carserver.Action_VehicleAction { + return &carserver.Action_VehicleAction{ + VehicleAction: &carserver.VehicleAction{ + VehicleActionMsg: &carserver.VehicleAction_EraseUserDataAction{}, + }, + } +} diff --git a/pkg/action/security_test.go b/pkg/action/security_test.go new file mode 100644 index 0000000..1db2a7d --- /dev/null +++ b/pkg/action/security_test.go @@ -0,0 +1,142 @@ +package action_test + +import ( + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + + "github.com/teslamotors/vehicle-command/pkg/action" + "github.com/teslamotors/vehicle-command/pkg/protocol/protobuf/carserver" +) + +var _ = Describe("Security Actions", func() { + Describe("SetValetMode", func() { + It("returns set valet mode action", func() { + action := action.SetValetMode(true, "valetPassword") + Expect(action).ToNot(BeNil()) + Expect(action.VehicleAction.GetVehicleActionMsg()).ToNot(BeNil()) + Expect(action.VehicleAction.GetVehicleActionMsg()).To(BeAssignableToTypeOf(&carserver.VehicleAction_VehicleControlSetValetModeAction{})) + valetModeAction := action.VehicleAction.GetVehicleActionMsg().(*carserver.VehicleAction_VehicleControlSetValetModeAction) + Expect(valetModeAction.VehicleControlSetValetModeAction.On).To(Equal(true)) + Expect(valetModeAction.VehicleControlSetValetModeAction.Password).To(Equal("valetPassword")) + }) + }) + + Describe("ResetValetPin", func() { + It("returns reset valet PIN action", func() { + action := action.ResetValetPin() + Expect(action).ToNot(BeNil()) + Expect(action.VehicleAction.GetVehicleActionMsg()).ToNot(BeNil()) + Expect(action.VehicleAction.GetVehicleActionMsg()).To(BeAssignableToTypeOf(&carserver.VehicleAction_VehicleControlResetValetPinAction{})) + }) + }) + + Describe("ResetPIN", func() { + It("returns reset PIN action", func() { + action := action.ResetPIN() + Expect(action).ToNot(BeNil()) + Expect(action.VehicleAction.GetVehicleActionMsg()).ToNot(BeNil()) + Expect(action.VehicleAction.GetVehicleActionMsg()).To(BeAssignableToTypeOf(&carserver.VehicleAction_VehicleControlResetPinToDriveAction{})) + }) + }) + + Describe("ActivateSpeedLimit", func() { + It("returns activating speed limit action", func() { + action := action.ActivateSpeedLimit("speedLimitPin") + Expect(action).ToNot(BeNil()) + Expect(action.VehicleAction.GetVehicleActionMsg()).ToNot(BeNil()) + Expect(action.VehicleAction.GetVehicleActionMsg()).To(BeAssignableToTypeOf(&carserver.VehicleAction_DrivingSpeedLimitAction{})) + speedLimitAction := action.VehicleAction.GetVehicleActionMsg().(*carserver.VehicleAction_DrivingSpeedLimitAction) + Expect(speedLimitAction.DrivingSpeedLimitAction.Activate).To(Equal(true)) + Expect(speedLimitAction.DrivingSpeedLimitAction.Pin).To(Equal("speedLimitPin")) + }) + }) + + Describe("DeactivateSpeedLimit", func() { + It("returns deactivate speed limit action", func() { + action := action.DeactivateSpeedLimit("speedLimitPin") + Expect(action).ToNot(BeNil()) + Expect(action.VehicleAction.GetVehicleActionMsg()).ToNot(BeNil()) + Expect(action.VehicleAction.GetVehicleActionMsg()).To(BeAssignableToTypeOf(&carserver.VehicleAction_DrivingSpeedLimitAction{})) + speedLimitAction := action.VehicleAction.GetVehicleActionMsg().(*carserver.VehicleAction_DrivingSpeedLimitAction) + Expect(speedLimitAction.DrivingSpeedLimitAction.Activate).To(Equal(false)) + Expect(speedLimitAction.DrivingSpeedLimitAction.Pin).To(Equal("speedLimitPin")) + }) + }) + + Describe("SpeedLimitSetLimitMPH", func() { + It("returns set speed limit MPH action", func() { + action := action.SpeedLimitSetLimitMPH(65) + Expect(action).ToNot(BeNil()) + Expect(action.VehicleAction.GetVehicleActionMsg()).ToNot(BeNil()) + Expect(action.VehicleAction.GetVehicleActionMsg()).To(BeAssignableToTypeOf(&carserver.VehicleAction_DrivingSetSpeedLimitAction{})) + speedLimitAction := action.VehicleAction.GetVehicleActionMsg().(*carserver.VehicleAction_DrivingSetSpeedLimitAction) + Expect(speedLimitAction.DrivingSetSpeedLimitAction.LimitMph).To(Equal(float64(65))) + }) + }) + + Describe("ClearSpeedLimitPIN", func() { + It("returns clear speed limit PIN action", func() { + action := action.ClearSpeedLimitPIN("speedLimitPin") + Expect(action).ToNot(BeNil()) + Expect(action.VehicleAction.GetVehicleActionMsg()).ToNot(BeNil()) + Expect(action.VehicleAction.GetVehicleActionMsg()).To(BeAssignableToTypeOf(&carserver.VehicleAction_DrivingClearSpeedLimitPinAction{})) + clearSpeedLimitPINAction := action.VehicleAction.GetVehicleActionMsg().(*carserver.VehicleAction_DrivingClearSpeedLimitPinAction) + Expect(clearSpeedLimitPINAction.DrivingClearSpeedLimitPinAction.Pin).To(Equal("speedLimitPin")) + }) + }) + + Describe("SetSentryMode", func() { + It("returns set sentry mode action", func() { + action := action.SetSentryMode(true) + Expect(action).ToNot(BeNil()) + Expect(action.VehicleAction.GetVehicleActionMsg()).ToNot(BeNil()) + Expect(action.VehicleAction.GetVehicleActionMsg()).To(BeAssignableToTypeOf(&carserver.VehicleAction_VehicleControlSetSentryModeAction{})) + sentryModeAction := action.VehicleAction.GetVehicleActionMsg().(*carserver.VehicleAction_VehicleControlSetSentryModeAction) + Expect(sentryModeAction.VehicleControlSetSentryModeAction.On).To(Equal(true)) + }) + }) + + Describe("SetGuestMode", func() { + It("returns set guest mode action", func() { + action := action.SetGuestMode(true) + Expect(action).ToNot(BeNil()) + Expect(action.VehicleAction.GetVehicleActionMsg()).ToNot(BeNil()) + Expect(action.VehicleAction.GetVehicleActionMsg()).To(BeAssignableToTypeOf(&carserver.VehicleAction_GuestModeAction{})) + guestModeAction := action.VehicleAction.GetVehicleActionMsg().(*carserver.VehicleAction_GuestModeAction) + Expect(guestModeAction.GuestModeAction.GuestModeActive).To(Equal(true)) + }) + }) + + Describe("SetPINToDrive", func() { + It("returns set PIN to drive action", func() { + action := action.SetPINToDrive(true, "pin") + Expect(action).ToNot(BeNil()) + Expect(action.VehicleAction.GetVehicleActionMsg()).ToNot(BeNil()) + Expect(action.VehicleAction.GetVehicleActionMsg()).To(BeAssignableToTypeOf(&carserver.VehicleAction_VehicleControlSetPinToDriveAction{})) + setPINToDriveAction := action.VehicleAction.GetVehicleActionMsg().(*carserver.VehicleAction_VehicleControlSetPinToDriveAction) + Expect(setPINToDriveAction.VehicleControlSetPinToDriveAction.On).To(Equal(true)) + Expect(setPINToDriveAction.VehicleControlSetPinToDriveAction.Password).To(Equal("pin")) + }) + }) + + Describe("TriggerHomelink", func() { + It("returns trigger homelink action", func() { + action := action.TriggerHomelink(37.7749, -122.4194) + Expect(action).ToNot(BeNil()) + Expect(action.VehicleAction.GetVehicleActionMsg()).ToNot(BeNil()) + Expect(action.VehicleAction.GetVehicleActionMsg()).To(BeAssignableToTypeOf(&carserver.VehicleAction_VehicleControlTriggerHomelinkAction{})) + triggerHomelinkAction := action.VehicleAction.GetVehicleActionMsg().(*carserver.VehicleAction_VehicleControlTriggerHomelinkAction) + Expect(triggerHomelinkAction.VehicleControlTriggerHomelinkAction.Location.Latitude).To(Equal(float32(37.7749))) + Expect(triggerHomelinkAction.VehicleControlTriggerHomelinkAction.Location.Longitude).To(Equal(float32(-122.4194))) + }) + }) + + Describe("EraseGuestData", func() { + It("returns erase guest data action", func() { + action := action.EraseGuestData() + Expect(action).ToNot(BeNil()) + Expect(action.VehicleAction.GetVehicleActionMsg()).ToNot(BeNil()) + Expect(action.VehicleAction.GetVehicleActionMsg()).To(BeAssignableToTypeOf(&carserver.VehicleAction_EraseUserDataAction{})) + }) + }) +}) diff --git a/pkg/proxy/command.go b/pkg/proxy/command.go index 4b968e6..05fb115 100644 --- a/pkg/proxy/command.go +++ b/pkg/proxy/command.go @@ -1,17 +1,13 @@ package proxy import ( - "context" "errors" "fmt" - "net/http" "strings" "time" - "github.com/teslamotors/vehicle-command/pkg/connector/inet" + "github.com/teslamotors/vehicle-command/pkg/action" "github.com/teslamotors/vehicle-command/pkg/protocol" - "github.com/teslamotors/vehicle-command/pkg/vehicle" - carserver "github.com/teslamotors/vehicle-command/pkg/protocol/protobuf/carserver" ) @@ -22,16 +18,16 @@ var ( // 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, + seatPositions = []action.SeatPosition{ + action.SeatFrontLeft, + action.SeatFrontRight, + action.SeatSecondRowLeft, + action.SeatSecondRowLeftBack, + action.SeatSecondRowCenter, + action.SeatSecondRowRight, + action.SeatSecondRowRightBack, + action.SeatThirdRowLeft, + action.SeatThirdRowRight, } dayNamesBitMask = map[string]int32{ @@ -58,7 +54,7 @@ var ( 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) { +func ExtractCommandAction(command string, params RequestParameters) (interface{}, error) { switch command { // Media controls case "adjust_volume": @@ -66,45 +62,43 @@ func ExtractCommandAction(ctx context.Context, command string, params RequestPar if err != nil { return nil, err } - return func(v *vehicle.Vehicle) error { return v.SetVolume(ctx, float32(volume)) }, nil + return action.SetVolume(float32(volume)) case "remote_boombox": return nil, ErrCommandNotImplemented case "media_toggle_playback": - return func(v *vehicle.Vehicle) error { return v.ToggleMediaPlayback(ctx) }, nil + return action.ToggleMediaPlayback(), nil // Climate Controls case "auto_conditioning_start": - return func(v *vehicle.Vehicle) error { return v.ClimateOn(ctx) }, nil + return action.ClimateOn(), nil case "auto_conditioning_stop": - return func(v *vehicle.Vehicle) error { return v.ClimateOff(ctx) }, nil + return action.ClimateOff(), nil case "charge_max_range": - return func(v *vehicle.Vehicle) error { return v.ChargeMaxRange(ctx) }, nil + return action.ChargeMaxRange(), 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 + return action.SetSeatCooler(level, seat) 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 + return action.SetSeatHeater(setting), nil case "remote_auto_seat_climate_request": seat, enabled, err := params.settingForAutoSeatPosition() if err != nil { return nil, err } - return func(v *vehicle.Vehicle) error { - return v.AutoSeatAndClimate(ctx, []vehicle.SeatPosition{seat}, enabled) - }, nil + return action.AutoSeatAndClimate([]action.SeatPosition{seat}, enabled), 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 + return action.SetSteeringWheelHeater(on), nil case "set_bioweapon_mode": on, err := params.getBool("on", true) if err != nil { @@ -114,7 +108,7 @@ func ExtractCommandAction(ctx context.Context, command string, params RequestPar if err != nil { return nil, err } - return func(v *vehicle.Vehicle) error { return v.SetBioweaponDefenseMode(ctx, on, override) }, nil + return action.SetBioweaponDefenseMode(on, override), nil case "set_cabin_overheat_protection": on, err := params.getBool("on", true) if err != nil { @@ -124,7 +118,7 @@ func ExtractCommandAction(ctx context.Context, command string, params RequestPar if err != nil { return nil, err } - return func(v *vehicle.Vehicle) error { return v.SetCabinOverheatProtection(ctx, on, fanOnly) }, nil + return action.SetCabinOverheatProtection(on, fanOnly), nil case "set_climate_keeper_mode": // 0 : off // 1 : On @@ -138,17 +132,13 @@ func ExtractCommandAction(ctx context.Context, command string, params RequestPar if err != nil { return nil, err } - return func(v *vehicle.Vehicle) error { - return v.SetClimateKeeperMode(ctx, vehicle.ClimateKeeperMode(mode), override) - }, nil + return action.SetClimateKeeperMode(action.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 + return action.SetCabinOverheatProtectionTemperature(action.Level(level)), nil case "set_preconditioning_max": on, err := params.getBool("on", true) if err != nil { @@ -158,7 +148,7 @@ func ExtractCommandAction(ctx context.Context, command string, params RequestPar if err != nil { return nil, err } - return func(v *vehicle.Vehicle) error { return v.SetPreconditioningMax(ctx, on, override) }, nil + return action.SetPreconditioningMax(on, override), nil case "set_temps": driverTemp, err := params.getNumber("driver_temp", false) if err != nil { @@ -168,51 +158,47 @@ func ExtractCommandAction(ctx context.Context, command string, params RequestPar if err != nil { return nil, err } - return func(v *vehicle.Vehicle) error { - return v.ChangeClimateTemp(ctx, float32(driverTemp), float32(passengerTemp)) - }, nil + return action.ChangeClimateTemp(float32(driverTemp), float32(passengerTemp)), nil // vehicle.Vehicle actuation commands + case "charge_port_door_open": + return action.OpenChargePort(), nil + case "charge_port_door_close": + return action.CloseChargePort(), nil + case "flash_lights": + return action.FlashLights(), nil + case "honk_horn": + return action.HonkHorn(), nil 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 + return action.OpenFrunk(), nil case "rear": - return func(v *vehicle.Vehicle) error { return v.OpenTrunk(ctx) }, nil + return action.OpenTrunk(), 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 + return action.OpenTrunk(), nil case "open_tonneau": - return func(v *vehicle.Vehicle) error { return v.OpenTonneau(ctx) }, nil + return action.OpenTonneau(), nil case "close_tonneau": - return func(v *vehicle.Vehicle) error { return v.CloseTonneau(ctx) }, nil + return action.CloseTonneau(), nil case "stop_tonneau": - return func(v *vehicle.Vehicle) error { return v.StopTonneau(ctx) }, nil + return action.StopTonneau(), nil // Charging controls case "charge_standard": - return func(v *vehicle.Vehicle) error { return v.ChargeStandardRange(ctx) }, nil + return action.ChargeStandardRange(), nil case "charge_start": - return func(v *vehicle.Vehicle) error { return v.ChargeStart(ctx) }, nil + return action.ChargeStart(), nil case "charge_stop": - return func(v *vehicle.Vehicle) error { return v.ChargeStop(ctx) }, nil + return action.ChargeStop(), 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 + return action.SetChargingAmps(int32(amps)), nil case "set_scheduled_charging": on, err := params.getBool("enable", true) if err != nil { @@ -222,20 +208,20 @@ func ExtractCommandAction(ctx context.Context, command string, params RequestPar if err != nil { return nil, err } - return func(v *vehicle.Vehicle) error { return v.ScheduleCharging(ctx, on, scheduledTime) }, nil + return action.ScheduleCharging(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 + return action.ChangeChargeLimit(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 + return action.ClearScheduledDeparture(), nil } offPeakPolicy, err := params.getPolicy("off_peak_charging_enabled", "off_peak_charging_weekdays_only") @@ -255,9 +241,7 @@ func ExtractCommandAction(ctx context.Context, command string, params RequestPar if err != nil { return nil, err } - return func(v *vehicle.Vehicle) error { - return v.ScheduleDeparture(ctx, departureTime, endOffPeakTime, preconditionPolicy, offPeakPolicy) - }, nil + return action.ScheduleDeparture(departureTime, endOffPeakTime, preconditionPolicy, offPeakPolicy) case "add_charge_schedule": lat, err := params.getNumber("lat", true) if err != nil { @@ -303,7 +287,7 @@ func ExtractCommandAction(ctx context.Context, command string, params RequestPar if err != nil { return nil, err } - schedule := vehicle.ChargeSchedule{ + schedule := carserver.ChargeSchedule{ DaysOfWeek: daysOfWeek, Latitude: float32(lat), Longitude: float32(lon), @@ -315,7 +299,7 @@ func ExtractCommandAction(ctx context.Context, command string, params RequestPar Enabled: enabled, OneTime: oneTime, } - return func(v *vehicle.Vehicle) error { return v.AddChargeSchedule(ctx, &schedule) }, nil + return action.AddChargeSchedule(&schedule), nil case "add_precondition_schedule": lat, err := params.getNumber("lat", true) if err != nil { @@ -349,7 +333,7 @@ func ExtractCommandAction(ctx context.Context, command string, params RequestPar if err != nil { return nil, err } - schedule := vehicle.PreconditionSchedule{ + schedule := carserver.PreconditionSchedule{ DaysOfWeek: daysOfWeek, Latitude: float32(lat), Longitude: float32(lon), @@ -358,19 +342,19 @@ func ExtractCommandAction(ctx context.Context, command string, params RequestPar OneTime: oneTime, Enabled: enabled, } - return func(v *vehicle.Vehicle) error { return v.AddPreconditionSchedule(ctx, &schedule) }, nil + return action.AddPreconditionSchedule(&schedule), nil case "remove_charge_schedule": id, err := params.getNumber("id", true) if err != nil { return nil, err } - return func(v *vehicle.Vehicle) error { return v.RemoveChargeSchedule(ctx, uint64(id)) }, nil + return action.RemoveChargeSchedule(uint64(id)), nil case "remove_precondition_schedule": id, err := params.getNumber("id", true) if err != nil { return nil, err } - return func(v *vehicle.Vehicle) error { return v.RemovePreconditionSchedule(ctx, uint64(id)) }, nil + return action.RemovePreconditionSchedule(uint64(id)), nil case "set_managed_charge_current_request": return nil, ErrCommandUseRESTAPI case "set_managed_charger_location": @@ -386,32 +370,32 @@ func ExtractCommandAction(ctx context.Context, command string, params RequestPar 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 + return action.SetPINToDrive(on, password), nil // Security + case "remote_start_drive": + return action.RemoteDrive(), nil case "door_lock": - return func(v *vehicle.Vehicle) error { return v.Lock(ctx) }, nil + return action.Lock(), nil case "door_unlock": - return func(v *vehicle.Vehicle) error { return v.Unlock(ctx) }, nil + return action.Unlock(), nil case "erase_user_data": - return func(v *vehicle.Vehicle) error { return v.EraseGuestData(ctx) }, nil + return action.EraseGuestData(), nil case "reset_pin_to_drive_pin": - return func(v *vehicle.Vehicle) error { return v.ResetPIN(ctx) }, nil + return action.ResetPIN(), nil case "reset_valet_pin": - return func(v *vehicle.Vehicle) error { return v.ResetValetPin(ctx) }, nil + return action.ResetValetPin(), 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 + return action.SetGuestMode(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 + return action.SetSentryMode(on), nil case "set_valet_mode": on, err := params.getBool("on", true) if err != nil { @@ -421,37 +405,37 @@ func ExtractCommandAction(ctx context.Context, command string, params RequestPar if err != nil { return nil, err } - return func(v *vehicle.Vehicle) error { return v.SetValetMode(ctx, on, password) }, nil + return action.SetValetMode(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 + return action.SetVehicleName(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 + return action.ActivateSpeedLimit(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 + return action.DeactivateSpeedLimit(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 + return action.ClearSpeedLimitPIN(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 + return action.SpeedLimitSetLimitMPH(speedMPH), nil case "trigger_homelink": lat, err := params.getNumber("lat", true) if err != nil { @@ -461,18 +445,16 @@ func ExtractCommandAction(ctx context.Context, command string, params RequestPar if err != nil { return nil, err } - return func(v *vehicle.Vehicle) error { return v.TriggerHomelink(ctx, float32(lat), float32(lon)) }, nil + return action.TriggerHomelink(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 + return action.ScheduleSoftwareUpdate(time.Duration(offsetSeconds) * time.Second), nil case "cancel_software_update": - return func(v *vehicle.Vehicle) error { return v.CancelSoftwareUpdate(ctx) }, nil + return action.CancelSoftwareUpdate(), nil // Sharing options. These endpoints often require server-side processing, which prevents strict // end-to-end authentication. case "navigation_request": @@ -485,14 +467,14 @@ func ExtractCommandAction(ctx context.Context, command string, params RequestPar } switch cmd { case "vent": - return func(v *vehicle.Vehicle) error { return v.VentWindows(ctx) }, nil + return action.VentWindows(), nil case "close": - return func(v *vehicle.Vehicle) error { return v.CloseWindows(ctx) }, nil + return action.CloseWindows(), nil default: return nil, errors.New("command must be 'vent' or 'close'") } default: - return nil, &inet.HttpError{Code: http.StatusBadRequest, Message: "{\"response\":null,\"error\":\"invalid_command\",\"error_description\":\"\"}"} + return nil, nil } } @@ -562,7 +544,7 @@ func (p RequestParameters) getDays(key string, required bool) (int32, error) { return mask, nil } -func (p RequestParameters) getPolicy(enabledKey string, weekdaysOnlyKey string) (vehicle.ChargingPolicy, error) { +func (p RequestParameters) getPolicy(enabledKey string, weekdaysOnlyKey string) (action.ChargingPolicy, error) { enabled, err := p.getBool(enabledKey, false) if err != nil { return 0, err @@ -572,12 +554,12 @@ func (p RequestParameters) getPolicy(enabledKey string, weekdaysOnlyKey string) return 0, err } if weekdaysOnly { - return vehicle.ChargingPolicyWeekdays, nil + return action.ChargingPolicyWeekdays, nil } if enabled { - return vehicle.ChargingPolicyAllDays, nil + return action.ChargingPolicyAllDays, nil } - return vehicle.ChargingPolicyOff, nil + return action.ChargingPolicyOff, nil } func (p RequestParameters) getTimeAfterMidnight(key string) (time.Duration, error) { @@ -589,7 +571,7 @@ func (p RequestParameters) getTimeAfterMidnight(key string) (time.Duration, erro return time.Duration(minutes) * time.Minute, nil } -func (p RequestParameters) settingForHeatSeatPosition() (map[vehicle.SeatPosition]vehicle.Level, error) { +func (p RequestParameters) settingForHeatSeatPosition() (map[action.SeatPosition]action.Level, error) { index, err := p.getNumber("seat_position", true) if err != nil { return nil, err @@ -603,24 +585,24 @@ func (p RequestParameters) settingForHeatSeatPosition() (map[vehicle.SeatPositio return nil, err } - return map[vehicle.SeatPosition]vehicle.Level{seatPositions[int(index)]: vehicle.Level(level)}, nil + return map[action.SeatPosition]action.Level{seatPositions[int(index)]: action.Level(level)}, nil } // Note: The API uses 0-3 -func (p RequestParameters) settingForCoolerSeatPosition() (vehicle.Level, vehicle.SeatPosition, error) { +func (p RequestParameters) settingForCoolerSeatPosition() (action.Level, action.SeatPosition, error) { position, err := p.getNumber("seat_position", true) if err != nil { return 0, 0, err } - var seat vehicle.SeatPosition + var seat action.SeatPosition switch carserver.HvacSeatCoolerActions_HvacSeatCoolerPosition_E(position) { case carserver.HvacSeatCoolerActions_HvacSeatCoolerPosition_FrontLeft: - seat = vehicle.SeatFrontLeft + seat = action.SeatFrontLeft case carserver.HvacSeatCoolerActions_HvacSeatCoolerPosition_FrontRight: - seat = vehicle.SeatFrontRight + seat = action.SeatFrontRight default: - seat = vehicle.SeatUnknown + seat = action.SeatUnknown } level, err := p.getNumber("seat_cooler_level", true) @@ -628,10 +610,10 @@ func (p RequestParameters) settingForCoolerSeatPosition() (vehicle.Level, vehicl return 0, 0, err } - return vehicle.Level(level - 1), seat, nil + return action.Level(level - 1), seat, nil } -func (p RequestParameters) settingForAutoSeatPosition() (vehicle.SeatPosition, bool, error) { +func (p RequestParameters) settingForAutoSeatPosition() (action.SeatPosition, bool, error) { position, err := p.getNumber("auto_seat_position", true) if err != nil { return 0, false, err @@ -642,14 +624,14 @@ func (p RequestParameters) settingForAutoSeatPosition() (vehicle.SeatPosition, b return 0, false, err } - var seat vehicle.SeatPosition + var seat action.SeatPosition switch carserver.AutoSeatClimateAction_AutoSeatPosition_E(position) { case carserver.AutoSeatClimateAction_AutoSeatPosition_FrontLeft: - seat = vehicle.SeatFrontLeft + seat = action.SeatFrontLeft case carserver.AutoSeatClimateAction_AutoSeatPosition_FrontRight: - seat = vehicle.SeatFrontRight + seat = action.SeatFrontRight default: - seat = vehicle.SeatUnknown + seat = action.SeatUnknown } return seat, enabled, nil diff --git a/pkg/proxy/command_test.go b/pkg/proxy/command_test.go index ccb3baf..80ad2fb 100644 --- a/pkg/proxy/command_test.go +++ b/pkg/proxy/command_test.go @@ -1,7 +1,6 @@ package proxy_test import ( - "context" "errors" "fmt" "net/http" @@ -9,12 +8,12 @@ import ( "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/proxy" - "github.com/teslamotors/vehicle-command/pkg/vehicle" + "google.golang.org/protobuf/proto" ) func TestExtractCommandAction(t *testing.T) { - ctx := context.Background() params := proxy.RequestParameters{ "volume": 5.0, "on": true, @@ -24,27 +23,40 @@ func TestExtractCommandAction(t *testing.T) { } tests := []struct { - command string - params proxy.RequestParameters - expectedFunc func(*vehicle.Vehicle) error - expected error + command string + params proxy.RequestParameters + expectedAction *carserver.Action_VehicleAction + expected error }{ - {"adjust_volume", params, func(v *vehicle.Vehicle) error { return v.SetVolume(ctx, 0.0) }, nil}, + {"adjust_volume", params, &carserver.Action_VehicleAction{ + VehicleAction: &carserver.VehicleAction{ + VehicleActionMsg: &carserver.VehicleAction_MediaUpdateVolume{ + MediaUpdateVolume: &carserver.MediaUpdateVolume{ + MediaVolume: &carserver.MediaUpdateVolume_VolumeAbsoluteFloat{ + VolumeAbsoluteFloat: 5, + }, + }, + }, + }, + }, 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) + action, err := proxy.ExtractCommandAction(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) } + + if test.expectedAction != nil && !proto.Equal(test.expectedAction.VehicleAction, action.(*carserver.Action_VehicleAction).VehicleAction) { + t.Errorf("expected action %+v not equal to actual %+v", test.expectedAction, action) + } } } diff --git a/pkg/proxy/proxy.go b/pkg/proxy/proxy.go index bae76c7..de7fd1d 100644 --- a/pkg/proxy/proxy.go +++ b/pkg/proxy/proxy.go @@ -18,43 +18,78 @@ import ( "github.com/teslamotors/vehicle-command/internal/authentication" "github.com/teslamotors/vehicle-command/internal/log" - "github.com/teslamotors/vehicle-command/pkg/account" "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" + "github.com/teslamotors/vehicle-command/pkg/protocol/protobuf/universalmessage" ) const ( DefaultTimeout = 10 * time.Second - maxRequestBodyBytes = 512 vinLength = 17 proxyProtocolVersion = "tesla-http-proxy/1.1.0" + xForwardedForHeader = "X-Forwarded-For" ) -func getAccount(req *http.Request) (*account.Account, error) { - token, ok := strings.CutPrefix(req.Header.Get("Authorization"), "Bearer ") - if !ok { - return nil, fmt.Errorf("client did not provide an OAuth token") - } - return account.New(token, proxyProtocolVersion) +type Vehicle interface { + Connect(context.Context) error + Disconnect() + StartSession(context.Context, []universalmessage.Domain) error + UpdateCachedSessions(*cache.SessionCache) error + ExecuteAction(context.Context, interface{}) error +} + +type Account interface { + GetVehicle(context.Context, string, authentication.ECDHPrivateKey, *cache.SessionCache) (Vehicle, error) + GetHost() string +} + +type AccountProvider func(oauthToken, userAgent string) (Account, error) + +// Response contains a server's response to a client request. +type Response struct { + Response interface{} `json:"response"` + Error string `json:"error"` + ErrDetails string `json:"error_description"` +} + +// vehicleResponse is the response format used by the vehicle's command API. +type vehicleResponse struct { + Result bool `json:"result"` + Reason string `json:"string"` +} + +// connectionHeaders are HTTP headers that are not forwarded. +var connectionHeaders = []string{ + "Proxy-Connection", + "Keep-Alive", + "Transfer-Encoding", + "Te", + "Upgrade", } +// contextKey is a type used to store values in context.Context. +type contextKey string + +const accountContext contextKey = "account" + // Proxy exposes an HTTP API for sending vehicle commands. type Proxy struct { Timeout time.Duration - commandKey protocol.ECDHPrivateKey - sessions *cache.SessionCache - vinLock sync.Map - unsupported sync.Map + commandKey protocol.ECDHPrivateKey + sessions *cache.SessionCache + vinLock sync.Map + unsupported sync.Map + handler http.Handler + accountProvider AccountProvider } -func (p *Proxy) markUnsupportedVIN(vin string) { +func (p *Proxy) markSignedCommandsUnsupportedVIN(vin string) { p.unsupported.Store(vin, true) } -func (p *Proxy) isNotSupported(vin string) bool { +func (p *Proxy) signedCommandUnsupported(vin string) bool { _, ok := p.unsupported.Load(vin) return ok } @@ -90,78 +125,50 @@ func (p *Proxy) unlockVIN(vin string) { // New creates an http proxy. // -// Vehicles must have the public part of skey enrolled on their keychains. (This is a -// command-authentication key, not a TLS key.) -func New(ctx context.Context, skey protocol.ECDHPrivateKey, cacheSize int) (*Proxy, error) { - return &Proxy{ - Timeout: DefaultTimeout, - commandKey: skey, - sessions: cache.New(cacheSize), - }, nil +// Vehicles must have the public part of skey enrolled on their keychains. +// (This is a command-authentication key, not a TLS key.) +func New(ctx context.Context, skey protocol.ECDHPrivateKey, cacheSize int, accountProvider AccountProvider) *Proxy { + proxy := &Proxy{ + Timeout: DefaultTimeout, + commandKey: skey, + sessions: cache.New(cacheSize), + accountProvider: accountProvider, + } + proxy.setupHandlers() + return proxy } -// Response contains a server's response to a client request. -type Response struct { - Response interface{} `json:"response"` - Error string `json:"error"` - ErrDetails string `json:"error_description"` +// setupHandlers sets up the HTTP handlers for the proxy. +func (p *Proxy) setupHandlers() { + handler := http.NewServeMux() + handler.HandleFunc("POST /api/1/vehicles/fleet_telemetry_config", p.handleFleetTelemetryConfig) + handler.HandleFunc("POST /api/1/vehicles/{vin}/command/{command}", p.handleVehicleCommand) + handler.HandleFunc("/api/1/", p.forwardRequest) + p.handler = handler } -type carResponse struct { - Result bool `json:"result"` - Reason string `json:"string"` -} +// ServeHTTP validates authentication, sets context, and passes the request to the appropriate handler. +func (p *Proxy) ServeHTTP(w http.ResponseWriter, req *http.Request) { + ctx, cancel := context.WithTimeout(req.Context(), p.Timeout) + defer cancel() -func writeJSONError(w http.ResponseWriter, code int, err error) { - reply := Response{} + log.Info("Received %s request for %s", req.Method, req.URL.Path) - var httpErr *inet.HttpError - var jsonBytes []byte - if errors.As(err, &httpErr) { - code = httpErr.Code - jsonBytes = []byte(err.Error()) - } else { - if err == nil { - reply.Error = http.StatusText(code) - } else if protocol.IsNominalError(err) { - // Response came from the car as opposed to Tesla's servers - reply.Response = &carResponse{Reason: err.Error()} - } else { - reply.Error = err.Error() - } - jsonBytes, err = json.Marshal(&reply) - if err != nil { - log.Error("Error serializing reply %+v: %s", &reply, err) - code = http.StatusInternalServerError - jsonBytes = []byte("{\"error\": \"internal server error\"}") - } - } - if code != http.StatusOK { - log.Error("Returning error %s", http.StatusText(code)) + acct, err := p.getAccount(req) + if err != nil { + writeResponseError(w, http.StatusUnauthorized, err) + return } - w.WriteHeader(code) - w.Header().Add("Content-Type", "application/json") - jsonBytes = append(jsonBytes, '\n') - w.Write(jsonBytes) -} -var connectionHeaders = []string{ - "Proxy-Connection", - "Keep-Alive", - "Transfer-Encoding", - "Te", - "Upgrade", + p.handler.ServeHTTP(w, req.WithContext(context.WithValue(ctx, accountContext, acct))) } // forwardRequest is the fallback handler for "/api/1/*". -// It forwards GET and POST requests to Tesla using the proxy's OAuth token. -func (p *Proxy) forwardRequest(host string, w http.ResponseWriter, req *http.Request) { - ctx, cancel := context.WithTimeout(context.Background(), p.Timeout) - defer cancel() - - proxyReq, err := http.NewRequestWithContext(ctx, req.Method, req.URL.String(), req.Body) +// It forwards requests to Tesla using the proxy's OAuth token. +func (p *Proxy) forwardRequest(w http.ResponseWriter, req *http.Request) { + proxyReq, err := http.NewRequestWithContext(req.Context(), req.Method, req.URL.String(), req.Body) if err != nil { - writeJSONError(w, http.StatusBadRequest, err) + writeResponseError(w, http.StatusBadRequest, err) return } proxyReq.Header = req.Header.Clone() @@ -172,20 +179,13 @@ func (p *Proxy) forwardRequest(host string, w http.ResponseWriter, req *http.Req clientIP, _, err := net.SplitHostPort(req.RemoteAddr) if err != nil { - writeJSONError(w, http.StatusBadRequest, err) + writeResponseError(w, http.StatusBadRequest, err) return } + proxyReq.Header.Set(xForwardedForHeader, strings.Join(append(req.Header.Values(xForwardedForHeader), clientIP), ", ")) - const xff = "X-Forwarded-For" - previous := req.Header.Values(xff) - if len(previous) == 0 { - proxyReq.Header.Add(xff, clientIP) - } else { - previous = append(previous, clientIP) - // If the client sent multiple XFF headers, flatten them. - proxyReq.Header.Set(xff, strings.Join(previous, ", ")) - } - proxyReq.URL.Host = host + acct := req.Context().Value(accountContext).(Account) + proxyReq.URL.Host = acct.GetHost() proxyReq.URL.Scheme = "https" log.Debug("Forwarding request to %s", proxyReq.URL.String()) @@ -193,9 +193,9 @@ func (p *Proxy) forwardRequest(host string, w http.ResponseWriter, req *http.Req resp, err := client.Do(proxyReq) if err != nil { if urlErr, ok := err.(*url.Error); ok && urlErr.Timeout() { - writeJSONError(w, http.StatusGatewayTimeout, urlErr) + writeResponseError(w, http.StatusGatewayTimeout, urlErr) } else { - writeJSONError(w, http.StatusBadGateway, err) + writeResponseError(w, http.StatusBadGateway, err) } return } @@ -210,178 +210,195 @@ func (p *Proxy) forwardRequest(host string, w http.ResponseWriter, req *http.Req } w.WriteHeader(resp.StatusCode) + w.Header().Add("Content-Type", resp.Header.Get("Content-Type")) io.Copy(w, resp.Body) } -func (p *Proxy) ServeHTTP(w http.ResponseWriter, req *http.Request) { - log.Info("Received %s request for %s", req.Method, req.URL.Path) - - acct, err := getAccount(req) - if err != nil { - writeJSONError(w, http.StatusForbidden, err) - return - } - - if strings.HasPrefix(req.URL.Path, "/api/1/vehicles/") { - path := strings.Split(req.URL.Path, "/") - if len(path) == 7 && path[5] == "command" { - command := path[6] - vin := path[4] - if len(vin) != vinLength { - writeJSONError(w, http.StatusNotFound, errors.New("expected 17-character VIN in path (do not user Fleet API ID)")) - return - } - if p.isNotSupported(vin) { - p.forwardRequest(acct.Host, w, req) - } else { - if err := p.handleVehicleCommand(acct, w, req, command, vin); err == ErrCommandUseRESTAPI { - p.forwardRequest(acct.Host, w, req) - } - } - return - } - if len(path) == 5 && path[4] == "fleet_telemetry_config" { - p.handleFleetTelemetryConfig(acct.Host, w, req) - return - } - } - p.forwardRequest(acct.Host, w, req) +type fleetTelemetryConfig struct { + VINs []string `json:"vins"` + Config jwt.MapClaims `json:"config"` } -func (p *Proxy) handleFleetTelemetryConfig(host string, w http.ResponseWriter, req *http.Request) { +func (p *Proxy) handleFleetTelemetryConfig(w http.ResponseWriter, req *http.Request) { log.Info("Processing fleet telemetry configuration...") defer req.Body.Close() body, err := io.ReadAll(req.Body) if err != nil { - writeJSONError(w, http.StatusBadRequest, fmt.Errorf("could not read request body: %s", err)) + writeResponseError(w, http.StatusBadRequest, fmt.Errorf("could not read request body: %s", err)) return } - var params struct { - VINs []string `json:"vins"` - Config jwt.MapClaims `json:"config"` - } + + var params fleetTelemetryConfig if err := json.Unmarshal(body, ¶ms); err != nil { - writeJSONError(w, http.StatusBadRequest, fmt.Errorf("could not parse JSON body: %s", err)) + writeResponseError(w, http.StatusBadRequest, fmt.Errorf("could not parse JSON body: %s", err)) return } // Let the server validate the VINs and config, the proxy just needs to sign if _, ok := params.Config["aud"]; ok { - log.Warning("Confuration 'aud' field will be overwritten") + log.Warning("Configuration 'aud' field will be overwritten") } if _, ok := params.Config["iss"]; ok { log.Warning("Configuration 'iss' field will be overwritten") } token, err := authentication.SignMessageForFleet(p.commandKey, "TelemetryClient", params.Config) if err != nil { - writeJSONError(w, http.StatusInternalServerError, fmt.Errorf("error signing configuration: %s", err)) + writeResponseError(w, http.StatusInternalServerError, fmt.Errorf("error signing configuration: %s", err)) return } // Forward the new request to Tesla's servers - jwtRequest := make(map[string]interface{}) - jwtRequest["vins"] = params.VINs - jwtRequest["token"] = token + jwtRequest := map[string]interface{}{ + "vins": params.VINs, + "token": token, + } bodyJSON, err := json.Marshal(jwtRequest) if err != nil { - writeJSONError(w, http.StatusInternalServerError, fmt.Errorf("error while serializing a request: %s", err)) + writeResponseError(w, http.StatusInternalServerError, fmt.Errorf("error while serializing a request: %s", err)) return } + req.Body = io.NopCloser(bytes.NewReader(bodyJSON)) req.URL, err = req.URL.Parse("/api/1/vehicles/fleet_telemetry_config_jws") if err != nil { - writeJSONError(w, http.StatusInternalServerError, fmt.Errorf("error creating proxied URL: %s", err)) + writeResponseError(w, http.StatusInternalServerError, fmt.Errorf("error creating proxied URL: %s", err)) return } + log.Debug("Posting data to %s: %s", req.URL.String(), bodyJSON) - p.forwardRequest(host, w, req) + p.forwardRequest(w, req) } -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() - - // Serialize commands sent to a specific VIN to avoid some complexities associated with sharing - // 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 err +func (p *Proxy) handleVehicleCommand(w http.ResponseWriter, req *http.Request) { + vin := req.PathValue("vin") + if len(vin) != vinLength { + writeResponseError(w, http.StatusNotFound, errors.New("expected 17-character VIN in path (do not use vehicle ID)")) + return } - defer p.unlockVIN(vin) - - car, commandToExecuteFunc, err := p.loadVehicleAndCommandFromRequest(ctx, acct, w, req, command, vin) - if err != nil { - return err + command := req.PathValue("command") + acct, ok := req.Context().Value(accountContext).(Account) + if !ok { + writeResponseError(w, http.StatusInternalServerError, errors.New("internal server error")) + return } - if err := car.Connect(ctx); err != nil { - writeJSONError(w, http.StatusInternalServerError, err) - return err + if p.signedCommandUnsupported(vin) { + p.forwardRequest(w, req) + return } - defer car.Disconnect() - if err := car.StartSession(ctx, nil); errors.Is(err, protocol.ErrProtocolNotSupported) { - p.markUnsupportedVIN(vin) - p.forwardRequest(acct.Host, w, req) - return err - } else if err != nil { - writeJSONError(w, http.StatusInternalServerError, err) - return err + params, err := p.parseRequestParameters(req) + if err != nil { + writeResponseError(w, http.StatusBadRequest, err) + return } - defer car.UpdateCachedSessions(p.sessions) - if err = commandToExecuteFunc(car); err == ErrCommandUseRESTAPI { - return err - } - if protocol.IsNominalError(err) { - writeJSONError(w, http.StatusOK, err) - return err + action, err := ExtractCommandAction(command, params) + if err == nil && action == nil { + writeResponseError(w, http.StatusNotFound, errors.New("unknown command")) + return } if err != nil { - writeJSONError(w, http.StatusInternalServerError, err) - return err + writeResponseError(w, http.StatusBadRequest, err) + return } - 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") + ctx := req.Context() + if err := p.lockVIN(ctx, vin); err != nil { + writeResponseError(w, http.StatusServiceUnavailable, err) + return } + defer p.unlockVIN(vin) - commandToExecuteFunc, err := extractCommandAction(ctx, req, command) - if err != nil { - writeJSONError(w, http.StatusBadRequest, err) - return nil, nil, err + vehicle, err := acct.GetVehicle(ctx, vin, p.commandKey, p.sessions) + if err != nil || vehicle == nil { + writeResponseError(w, http.StatusInternalServerError, err) + return } - car, err := acct.GetVehicle(ctx, vin, p.commandKey, p.sessions) - if err != nil || car == nil { - writeJSONError(w, http.StatusInternalServerError, err) - return nil, nil, err + if err := p.sendActionToVehicle(ctx, vehicle, action); err != nil { + if errors.Is(err, protocol.ErrProtocolNotSupported) { + p.markSignedCommandsUnsupportedVIN(vin) + p.forwardRequest(w, req) + return + } + writeResponseError(w, http.StatusInternalServerError, err) + return } - return car, commandToExecuteFunc, err + writeJSON(w, http.StatusOK, []byte(`{"response":{"result":true,"reason":""}}`)) } -func extractCommandAction(ctx context.Context, req *http.Request, command string) (func(*vehicle.Vehicle) error, error) { +func (p *Proxy) parseRequestParameters(req *http.Request) (RequestParameters, error) { var params RequestParameters body, err := io.ReadAll(req.Body) if err != nil { - return nil, &inet.HttpError{Code: http.StatusBadRequest, Message: "could not read request body"} + return params, fmt.Errorf("could not read request body: %s", err) } if len(body) > 0 { if err := json.Unmarshal(body, ¶ms); err != nil { - return nil, &inet.HttpError{Code: http.StatusBadRequest, Message: "error occurred while parsing request parameters"} + return params, fmt.Errorf("error occurred while parsing request parameters: %s", err) } } + return params, nil +} - return ExtractCommandAction(ctx, command, params) +// sendActionToVehicle connects to the vehicle, starts a session, and executes the action. +func (p *Proxy) sendActionToVehicle(ctx context.Context, vehicle Vehicle, action interface{}) error { + if err := vehicle.Connect(ctx); err != nil { + return err + } + defer vehicle.Disconnect() + + if err := vehicle.StartSession(ctx, nil); err != nil { + return err + } + + defer vehicle.UpdateCachedSessions(p.sessions) + + return vehicle.ExecuteAction(ctx, action) +} + +func (p *Proxy) getAccount(req *http.Request) (Account, error) { + token, ok := strings.CutPrefix(req.Header.Get("Authorization"), "Bearer ") + if !ok { + return nil, fmt.Errorf("client did not provide an OAuth token") + } + return p.accountProvider(token, proxyProtocolVersion) +} + +func writeResponseError(w http.ResponseWriter, code int, err error) { + reply := Response{} + + var httpErr *inet.HttpError + var jsonBytes []byte + if errors.As(err, &httpErr) { + code = httpErr.Code + jsonBytes = []byte(err.Error()) + } else { + if err == nil { + reply.Error = http.StatusText(code) + } else if protocol.IsNominalError(err) { + // Response came from the vehicle as opposed to Tesla's servers + reply.Response = &vehicleResponse{Reason: err.Error()} + } else { + reply.Error = err.Error() + } + jsonBytes, err = json.Marshal(&reply) + if err != nil { + log.Error("Error serializing reply %+v: %s", &reply, err) + code = http.StatusInternalServerError + jsonBytes = []byte(`{"error": "internal server error"}`) + } + } + if code != http.StatusOK { + log.Error("Returning error %s", http.StatusText(code)) + } + writeJSON(w, code, jsonBytes) +} + +func writeJSON(w http.ResponseWriter, code int, jsonBytes []byte) { + w.WriteHeader(code) + w.Header().Add("Content-Type", "application/json") + w.Write(jsonBytes) } diff --git a/pkg/proxy/proxy_suite_test.go b/pkg/proxy/proxy_suite_test.go new file mode 100644 index 0000000..ada35a5 --- /dev/null +++ b/pkg/proxy/proxy_suite_test.go @@ -0,0 +1,13 @@ +package proxy_test + +import ( + "testing" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +func TestProxy(t *testing.T) { + RegisterFailHandler(Fail) + RunSpecs(t, "Proxy Suite") +} diff --git a/pkg/proxy/proxy_test.go b/pkg/proxy/proxy_test.go new file mode 100644 index 0000000..7d8ac32 --- /dev/null +++ b/pkg/proxy/proxy_test.go @@ -0,0 +1,425 @@ +package proxy_test + +import ( + "bytes" + "context" + "crypto/rand" + "encoding/base64" + "encoding/json" + "fmt" + "io" + "net/http" + "net/http/httptest" + "strings" + "time" + + "github.com/jarcoal/httpmock" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + "go.uber.org/mock/gomock" + + "github.com/teslamotors/vehicle-command/internal/authentication" + "github.com/teslamotors/vehicle-command/mocks" + "github.com/teslamotors/vehicle-command/pkg/protocol" + "github.com/teslamotors/vehicle-command/pkg/protocol/protobuf/carserver" + "github.com/teslamotors/vehicle-command/pkg/protocol/protobuf/vcsec" + "github.com/teslamotors/vehicle-command/pkg/proxy" +) + +const ( + vin = "TESLA000000000001" +) + +var ( + validJwt = "x." + b64Encode(fmt.Sprintf(`{"aud": ["%s"]}`, "example.com")) + ".y" + authorizationToken = "Bearer " + validJwt +) + +func b64Encode(payload string) string { + return base64.RawStdEncoding.EncodeToString([]byte(payload)) +} + +var _ = Describe("Proxy", func() { + var ( + ctrl *gomock.Controller + p *proxy.Proxy + mockAccount *mocks.ProxyAccount + validToken bool + signerKey authentication.ECDHPrivateKey + ) + + sendRequest := func(method, path string, token string, body []byte) *httptest.ResponseRecorder { + req := httptest.NewRequest(method, path, bytes.NewReader(body)) + req.Header.Set("Authorization", token) + rr := httptest.NewRecorder() + p.ServeHTTP(rr, req) + return rr + } + + BeforeEach(func() { + var err error + validToken = true + ctrl = gomock.NewController(GinkgoT()) + mockAccount = mocks.NewProxyAccount(ctrl) + signerKey, err = authentication.NewECDHPrivateKey(rand.Reader) + Expect(err).NotTo(HaveOccurred()) + p = proxy.New(context.Background(), signerKey, 0, func(oauthToken, userAgent string) (proxy.Account, error) { + if validToken { + return mockAccount, nil + } + return nil, fmt.Errorf("invalid token") + }) + Expect(err).ToNot(HaveOccurred()) + DeferCleanup(func() { + ctrl.Finish() + }) + }) + + Context("vehicle commands", func() { + Context("invalid VIN", func() { + It("returns not found", func() { + rr := sendRequest(http.MethodPost, "/api/1/vehicles/ABC/command/honk_horn", authorizationToken, nil) + Expect(rr.Code).To(Equal(http.StatusNotFound)) + }) + }) + + Context("invalid auth token", func() { + It("returns unauthorized", func() { + validToken = false + rr := sendRequest(http.MethodPost, "/api/1/vehicles/ABC/command/honk_horn", "Bearer invalid", nil) + Expect(rr.Code).To(Equal(http.StatusUnauthorized)) + }) + }) + + Context("signed command", func() { + It("returns successful response", func() { + vehicle := mocks.NewProxyVehicle(ctrl) + vehicle.EXPECT().Connect(gomock.Any()).Return(nil) + vehicle.EXPECT().StartSession(gomock.Any(), gomock.Any()).Return(nil) + vehicle.EXPECT().ExecuteAction(gomock.Any(), gomock.AssignableToTypeOf(&carserver.Action_VehicleAction{})).Return(nil) + vehicle.EXPECT().UpdateCachedSessions(gomock.Any()).Return(nil) + vehicle.EXPECT().Disconnect() + mockAccount.EXPECT().GetVehicle(gomock.Any(), vin, gomock.Any(), gomock.Any()).Return(vehicle, nil) + + rr := sendRequest(http.MethodPost, fmt.Sprintf("/api/1/vehicles/%s/command/honk_horn", vin), authorizationToken, nil) + Expect(rr.Code).To(Equal(http.StatusOK)) + Expect(rr.Body.String()).To(MatchJSON(`{"response":{"result":true,"reason":""}}`)) + }) + }) + + Context("unsigned command", func() { + It("returns successful response", func() { + vehicle := mocks.NewProxyVehicle(ctrl) + vehicle.EXPECT().Connect(gomock.Any()).Return(nil) + vehicle.EXPECT().StartSession(gomock.Any(), gomock.Any()).Return(nil) + vehicle.EXPECT().ExecuteAction(gomock.Any(), gomock.AssignableToTypeOf(&vcsec.UnsignedMessage{})).Return(nil) + vehicle.EXPECT().UpdateCachedSessions(gomock.Any()).Return(nil) + vehicle.EXPECT().Disconnect() + mockAccount.EXPECT().GetVehicle(gomock.Any(), vin, gomock.Any(), gomock.Any()).Return(vehicle, nil) + + rr := sendRequest(http.MethodPost, fmt.Sprintf("/api/1/vehicles/%s/command/door_lock", vin), authorizationToken, nil) + Expect(rr.Code).To(Equal(http.StatusOK)) + Expect(rr.Body.String()).To(MatchJSON(`{"response":{"result":true,"reason":""}}`)) + }) + }) + + Context("signed command not supported", func() { + It("forwards unsigned command", func() { + httpmock.Activate() + defer httpmock.DeactivateAndReset() + + vehicle := mocks.NewProxyVehicle(ctrl) + vehicle.EXPECT().Connect(gomock.Any()).Return(nil) + vehicle.EXPECT().StartSession(gomock.Any(), gomock.Any()).Return(protocol.ErrProtocolNotSupported) + mockAccount.EXPECT().GetHost().Return("example.com") + httpmock.RegisterResponder(http.MethodPost, fmt.Sprintf("https://%s/api/1/vehicles/%s/command/honk_horn", "example.com", vin), func(r *http.Request) (*http.Response, error) { + Expect(r.Header.Get("Authorization")).To(Equal("Bearer " + validJwt)) + return httpmock.NewJsonResponse(http.StatusOK, map[string]interface{}{ + "response": map[string]interface{}{ + "result": true, + "reason": "", + }, + }) + }) + vehicle.EXPECT().Disconnect() + mockAccount.EXPECT().GetVehicle(gomock.Any(), vin, gomock.Any(), gomock.Any()).Return(vehicle, nil) + + rr := sendRequest(http.MethodPost, fmt.Sprintf("/api/1/vehicles/%s/command/honk_horn", vin), authorizationToken, nil) + Expect(rr.Code).To(Equal(http.StatusOK)) + Expect(rr.Body.String()).To(MatchJSON(`{"response":{"result":true,"reason":""}}`)) + }) + + It("does not try establishing session on subsequent requests for vehicles that do not support signed commands", func() { + httpmock.Activate() + defer httpmock.DeactivateAndReset() + + vehicle := mocks.NewProxyVehicle(ctrl) + vehicle.EXPECT().Connect(gomock.Any()).Return(nil).Times(1) + vehicle.EXPECT().StartSession(gomock.Any(), gomock.Any()).Return(protocol.ErrProtocolNotSupported).Times(1) + vehicle.EXPECT().Disconnect().Times(1) + httpmock.RegisterResponder(http.MethodPost, fmt.Sprintf("https://%s/api/1/vehicles/%s/command/honk_horn", "example.com", vin), func(r *http.Request) (*http.Response, error) { + Expect(r.Header.Get("Authorization")).To(Equal("Bearer " + validJwt)) + return httpmock.NewJsonResponse(http.StatusOK, map[string]interface{}{ + "response": map[string]interface{}{ + "result": true, + "reason": "", + }, + }) + }) + + mockAccount.EXPECT().GetHost().Return("example.com").AnyTimes() + mockAccount.EXPECT().GetVehicle(gomock.Any(), vin, gomock.Any(), gomock.Any()).Return(vehicle, nil) + + for i := 0; i < 3; i++ { + rr := sendRequest(http.MethodPost, fmt.Sprintf("/api/1/vehicles/%s/command/honk_horn", vin), authorizationToken, nil) + Expect(rr.Code).To(Equal(http.StatusOK)) + Expect(rr.Body.String()).To(MatchJSON(`{"response":{"result":true,"reason":""}}`)) + } + }) + + It("returns errors", func() { + httpmock.Activate() + defer httpmock.DeactivateAndReset() + + vehicle := mocks.NewProxyVehicle(ctrl) + vehicle.EXPECT().Connect(gomock.Any()).Return(nil) + vehicle.EXPECT().StartSession(gomock.Any(), gomock.Any()).Return(protocol.ErrProtocolNotSupported) + mockAccount.EXPECT().GetHost().Return("example.com") + httpmock.RegisterResponder(http.MethodPost, fmt.Sprintf("https://%s/api/1/vehicles/%s/command/honk_horn", "example.com", vin), func(r *http.Request) (*http.Response, error) { + Expect(r.Header.Get("Authorization")).To(Equal("Bearer " + validJwt)) + return httpmock.NewJsonResponse(http.StatusRequestTimeout, map[string]interface{}{ + "error": "vehicle offline", + }) + }) + vehicle.EXPECT().Disconnect() + mockAccount.EXPECT().GetVehicle(gomock.Any(), vin, gomock.Any(), gomock.Any()).Return(vehicle, nil) + + rr := sendRequest(http.MethodPost, fmt.Sprintf("/api/1/vehicles/%s/command/honk_horn", vin), authorizationToken, nil) + Expect(rr.Code).To(Equal(http.StatusRequestTimeout)) + Expect(rr.Body.String()).To(MatchJSON(`{"error":"vehicle offline"}`)) + }) + + It("fails for unknown command", func() { + rr := sendRequest(http.MethodPost, fmt.Sprintf("/api/1/vehicles/%s/command/unknown", vin), authorizationToken, nil) + Expect(rr.Code).To(Equal(http.StatusNotFound)) + }) + }) + }) + + Describe("fleet telemetry config", func() { + Context("invalid json body", func() { + It("returns 400 bad request", func() { + req := httptest.NewRequest(http.MethodPost, "/api/1/vehicles/fleet_telemetry_config", bytes.NewReader([]byte("invalid"))) + req.Header.Set("Authorization", authorizationToken) + + rr := httptest.NewRecorder() + p.ServeHTTP(rr, req) + + Expect(rr.Code).To(Equal(http.StatusBadRequest)) + }) + }) + + It("signs and forwards jws", func() { + httpmock.Activate() + defer httpmock.DeactivateAndReset() + + httpmock.RegisterResponder(http.MethodPost, fmt.Sprintf("https://%s/api/1/vehicles/fleet_telemetry_config_jws", "example.com"), func(r *http.Request) (*http.Response, error) { + Expect(r.Header.Get("Authorization")).To(Equal("Bearer " + validJwt)) + + type Body struct { + Token string `json:"token"` + Vins []string `json:"vins"` + } + var body Body + bodyBytes, err := io.ReadAll(r.Body) + Expect(err).ToNot(HaveOccurred()) + defer r.Body.Close() + + err = json.Unmarshal(bodyBytes, &body) + Expect(err).ToNot(HaveOccurred()) + + Expect(body.Vins).To(Equal([]string{vin})) + tokenParts := strings.Split(body.Token, ".") + Expect(len(tokenParts)).To(Equal(3)) + + var claims map[string]interface{} + claimsStr, err := base64.RawURLEncoding.DecodeString(tokenParts[1]) + Expect(err).ToNot(HaveOccurred()) + err = json.Unmarshal(claimsStr, &claims) + + Expect(claims["aud"]).To(Equal("com.tesla.fleet.TelemetryClient")) + Expect(claims["iss"]).To(Equal(base64.StdEncoding.EncodeToString(signerKey.PublicBytes()))) + Expect(claims["fields"]).To(Equal(map[string]interface{}{ + "Soc": map[string]interface{}{ + "interval_seconds": float64(1), + }, + })) + + return httpmock.NewJsonResponse(http.StatusOK, map[string]interface{}{ + "response": map[string]interface{}{ + "updated_vehicles": 1, + }, + }) + }) + + mockAccount.EXPECT().GetHost().Return("example.com") + + body := map[string]interface{}{ + "vins": []string{vin}, + "config": map[string]interface{}{ + "fields": map[string]interface{}{ + "Soc": map[string]interface{}{ + "interval_seconds": 1, + }, + }, + "aud": "should be overwritten", + "iss": "should be overwritten", + }, + } + bodyBytes, err := json.Marshal(body) + Expect(err).ToNot(HaveOccurred()) + rr := sendRequest(http.MethodPost, "/api/1/vehicles/fleet_telemetry_config", authorizationToken, bodyBytes) + Expect(rr.Code).To(Equal(http.StatusOK)) + Expect(rr.Body.String()).To(MatchJSON(`{"response":{"updated_vehicles":1}}`)) + }) + }) + + Describe("forward request", func() { + Describe("X-Forwarded-For header", func() { + It("adds header", func() { + httpmock.Activate() + defer httpmock.DeactivateAndReset() + + httpmock.RegisterResponder(http.MethodGet, "https://example.com/api/1/unknown", func(r *http.Request) (*http.Response, error) { + Expect(r.Header.Get("X-Forwarded-For")).To(Equal("1.2.3.4")) + return httpmock.NewJsonResponse(http.StatusOK, map[string]interface{}{}) + }) + + req := httptest.NewRequest(http.MethodGet, "/api/1/unknown", nil) + req.Header.Set("Authorization", authorizationToken) + req.RemoteAddr = "1.2.3.4:5678" + mockAccount.EXPECT().GetHost().Return("example.com") + + rr := httptest.NewRecorder() + p.ServeHTTP(rr, req) + + Expect(rr.Code).To(Equal(http.StatusOK)) + }) + + It("adds to existing X-Forwarded-For header", func() { + httpmock.Activate() + defer httpmock.DeactivateAndReset() + + httpmock.RegisterResponder(http.MethodGet, "https://example.com/api/1/unknown", func(r *http.Request) (*http.Response, error) { + Expect(r.Header.Get("X-Forwarded-For")).To(Equal("5.6.7.8, 9.10.11.12, 1.2.3.4")) + return httpmock.NewJsonResponse(http.StatusOK, map[string]interface{}{}) + }) + + req := httptest.NewRequest(http.MethodGet, "/api/1/unknown", nil) + req.Header.Set("Authorization", authorizationToken) + req.RemoteAddr = "1.2.3.4:5678" + req.Header.Set("X-Forwarded-For", "5.6.7.8, 9.10.11.12") + mockAccount.EXPECT().GetHost().Return("example.com") + + rr := httptest.NewRecorder() + p.ServeHTTP(rr, req) + + Expect(rr.Code).To(Equal(http.StatusOK)) + }) + }) + + Describe("per-hop headers", func() { + It("removes before forwarding", func() { + httpmock.Activate() + defer httpmock.DeactivateAndReset() + + httpmock.RegisterResponder(http.MethodGet, "https://example.com/api/1/unknown", func(r *http.Request) (*http.Response, error) { + Expect(r.Header.Get("Proxy-Connection")).To(Equal("")) + Expect(r.Header.Get("Keep-Alive")).To(Equal("")) + Expect(r.Header.Get("Transfer-Encoding")).To(Equal("")) + Expect(r.Header.Get("Te")).To(Equal("")) + Expect(r.Header.Get("Upgrade")).To(Equal("")) + Expect(r.Header.Get("X-TXID")).To(Equal("abc123")) + return httpmock.NewJsonResponse(http.StatusOK, map[string]interface{}{}) + }) + + req := httptest.NewRequest(http.MethodGet, "/api/1/unknown", nil) + req.Header.Set("Authorization", authorizationToken) + req.Header.Set("Proxy-Connection", "keep-alive") + req.Header.Set("Keep-Alive", "timeout=5") + req.Header.Set("Transfer-Encoding", "chunked") + req.Header.Set("Te", "trailers") + req.Header.Set("Upgrade", "websocket") + req.Header.Set("X-TXID", "abc123") + mockAccount.EXPECT().GetHost().Return("example.com") + + rr := httptest.NewRecorder() + p.ServeHTTP(rr, req) + + Expect(rr.Code).To(Equal(http.StatusOK)) + }) + + It("removes from response", func() { + httpmock.Activate() + defer httpmock.DeactivateAndReset() + + httpmock.RegisterResponder(http.MethodGet, "https://example.com/api/1/unknown", func(r *http.Request) (*http.Response, error) { + resp, err := httpmock.NewJsonResponse(http.StatusOK, map[string]interface{}{}) + Expect(err).ToNot(HaveOccurred()) + resp.Header.Set("Proxy-Connection", "keep-alive") + resp.Header.Set("Keep-Alive", "timeout=5") + resp.Header.Set("Transfer-Encoding", "chunked") + resp.Header.Set("Te", "trailers") + resp.Header.Set("Upgrade", "websocket") + resp.Header.Set("X-TXID", "abc123") + return resp, nil + }) + + req := httptest.NewRequest(http.MethodGet, "/api/1/unknown", nil) + req.Header.Set("Authorization", authorizationToken) + mockAccount.EXPECT().GetHost().Return("example.com") + + rr := httptest.NewRecorder() + p.ServeHTTP(rr, req) + + Expect(rr.Code).To(Equal(http.StatusOK)) + Expect(rr.Header().Get("Proxy-Connection")).To(Equal("")) + Expect(rr.Header().Get("Keep-Alive")).To(Equal("")) + Expect(rr.Header().Get("Transfer-Encoding")).To(Equal("")) + Expect(rr.Header().Get("Te")).To(Equal("")) + Expect(rr.Header().Get("Upgrade")).To(Equal("")) + Expect(rr.Header().Get("X-TXID")).To(Equal("abc123")) + }) + }) + + It("times out", func() { + httpmock.Activate() + defer httpmock.DeactivateAndReset() + + httpmock.RegisterResponder(http.MethodGet, "https://example.com/api/1/unknown", func(r *http.Request) (*http.Response, error) { + time.Sleep(50 * time.Millisecond) + return httpmock.NewJsonResponse(http.StatusOK, map[string]interface{}{}) + }) + + p.Timeout = 25 * time.Millisecond + req := httptest.NewRequest(http.MethodGet, "/api/1/unknown", nil) + req.Header.Set("Authorization", authorizationToken) + mockAccount.EXPECT().GetHost().Return("example.com") + + rr := httptest.NewRecorder() + p.ServeHTTP(rr, req) + + Expect(rr.Code).To(Equal(http.StatusGatewayTimeout)) + }) + }) + + It("returns 404 for path not starting with /api/1/", func() { + req := httptest.NewRequest(http.MethodGet, "/unknown", nil) + req.Header.Set("Authorization", authorizationToken) + + rr := httptest.NewRecorder() + p.ServeHTTP(rr, req) + + Expect(rr.Code).To(Equal(http.StatusNotFound)) + }) +}) diff --git a/pkg/vehicle/action.go b/pkg/vehicle/action.go new file mode 100644 index 0000000..9d1de70 --- /dev/null +++ b/pkg/vehicle/action.go @@ -0,0 +1,70 @@ +package vehicle + +import ( + "context" + "fmt" + + "google.golang.org/protobuf/proto" + + "github.com/teslamotors/vehicle-command/pkg/protocol" + carserver "github.com/teslamotors/vehicle-command/pkg/protocol/protobuf/carserver" + universal "github.com/teslamotors/vehicle-command/pkg/protocol/protobuf/universalmessage" + "github.com/teslamotors/vehicle-command/pkg/protocol/protobuf/vcsec" +) + +// ExecuteAction executes an action on the vehicle. +// +// The action can be a *carserver.Action_VehicleAction or a *vcsec.UnsignedMessage. +// Actions are created using the action package. +func (v *Vehicle) ExecuteAction(ctx context.Context, action interface{}) error { + switch action := action.(type) { + case *carserver.Action_VehicleAction: + _, err := v.getCarServerResponse(ctx, action) + return err + case *vcsec.UnsignedMessage: + encodedPayload, err := proto.Marshal(action) + if err != nil { + return err + } + + _, err = v.getVCSECResult(ctx, encodedPayload, v.authMethod, isUnsignedActionDone) + return err + default: + return fmt.Errorf("unsupported action type: %T", action) + } +} + +func isUnsignedActionDone(fromVCSEC *vcsec.FromVCSECMessage) (bool, error) { + if fromVCSEC.GetCommandStatus() == nil { + return true, nil + } + return false, nil +} + +func (v *Vehicle) getCarServerResponse(ctx context.Context, action *carserver.Action_VehicleAction) (*carserver.Response, error) { + payload := carserver.Action{ + ActionMsg: action, + } + encodedPayload, err := proto.Marshal(&payload) + if err != nil { + return nil, err + } + responsePayload, err := v.Send(ctx, universal.Domain_DOMAIN_INFOTAINMENT, encodedPayload, v.authMethod) + if err != nil { + return nil, err + } + + var response carserver.Response + if err := proto.Unmarshal(responsePayload, &response); err != nil { + return nil, &protocol.CommandError{Err: fmt.Errorf("unable to parse vehicle response: %w", err), PossibleSuccess: true, PossibleTemporary: false} + } + + if response.GetActionStatus().GetResult() == carserver.OperationStatus_E_OPERATIONSTATUS_ERROR { + description := response.GetActionStatus().GetResultReason().GetPlainText() + if description == "" { + description = "unspecified error" + } + return nil, &protocol.NominalError{Details: protocol.NewError("car could not execute command: "+description, false, false)} + } + return &response, nil +} diff --git a/pkg/vehicle/actions.go b/pkg/vehicle/actions.go deleted file mode 100644 index c759c48..0000000 --- a/pkg/vehicle/actions.go +++ /dev/null @@ -1,132 +0,0 @@ -// File implements commands that trigger physical vehicle actions. - -package vehicle - -import ( - "context" - - carserver "github.com/teslamotors/vehicle-command/pkg/protocol/protobuf/carserver" - "github.com/teslamotors/vehicle-command/pkg/protocol/protobuf/vcsec" -) - -func (v *Vehicle) ActuateTrunk(ctx context.Context) error { - return v.executeClosureAction(ctx, vcsec.ClosureMoveType_E_CLOSURE_MOVE_TYPE_MOVE, ClosureTrunk) -} - -// OpenTrunk opens the trunk, but note that CloseTrunk is not available on all vehicle types. -func (v *Vehicle) OpenTrunk(ctx context.Context) error { - return v.executeClosureAction(ctx, vcsec.ClosureMoveType_E_CLOSURE_MOVE_TYPE_MOVE, ClosureTrunk) -} - -// CloseTrunk is not available on all vehicle types. -func (v *Vehicle) CloseTrunk(ctx context.Context) error { - return v.executeClosureAction(ctx, vcsec.ClosureMoveType_E_CLOSURE_MOVE_TYPE_CLOSE, ClosureTrunk) -} - -// OpenTrunk opens the frunk. There is no remote way to close the frunk! -func (v *Vehicle) OpenFrunk(ctx context.Context) error { - return v.executeClosureAction(ctx, vcsec.ClosureMoveType_E_CLOSURE_MOVE_TYPE_MOVE, ClosureFrunk) -} -func (v *Vehicle) HonkHorn(ctx context.Context) error { - return v.executeCarServerAction(ctx, - &carserver.Action_VehicleAction{ - VehicleAction: &carserver.VehicleAction{ - VehicleActionMsg: &carserver.VehicleAction_VehicleControlHonkHornAction{ - VehicleControlHonkHornAction: &carserver.VehicleControlHonkHornAction{}, - }, - }, - }) -} - -func (v *Vehicle) FlashLights(ctx context.Context) error { - return v.executeCarServerAction(ctx, - &carserver.Action_VehicleAction{ - VehicleAction: &carserver.VehicleAction{ - VehicleActionMsg: &carserver.VehicleAction_VehicleControlFlashLightsAction{ - VehicleControlFlashLightsAction: &carserver.VehicleControlFlashLightsAction{}, - }, - }, - }) -} - -func (v *Vehicle) ChangeSunroofState(ctx context.Context, sunroofLevel int32) error { - return v.executeCarServerAction(ctx, - &carserver.Action_VehicleAction{ - VehicleAction: &carserver.VehicleAction{ - VehicleActionMsg: &carserver.VehicleAction_VehicleControlSunroofOpenCloseAction{ - VehicleControlSunroofOpenCloseAction: &carserver.VehicleControlSunroofOpenCloseAction{ - SunroofLevel: &carserver.VehicleControlSunroofOpenCloseAction_AbsoluteLevel{ - AbsoluteLevel: sunroofLevel, - }, - }, - }, - }, - }) -} - -func (v *Vehicle) CloseWindows(ctx context.Context) error { - return v.executeCarServerAction(ctx, - &carserver.Action_VehicleAction{ - VehicleAction: &carserver.VehicleAction{ - VehicleActionMsg: &carserver.VehicleAction_VehicleControlWindowAction{ - VehicleControlWindowAction: &carserver.VehicleControlWindowAction{ - Action: &carserver.VehicleControlWindowAction_Close{ - Close: &carserver.Void{}, - }, - }, - }, - }, - }) -} - -func (v *Vehicle) VentWindows(ctx context.Context) error { - return v.executeCarServerAction(ctx, - &carserver.Action_VehicleAction{ - VehicleAction: &carserver.VehicleAction{ - VehicleActionMsg: &carserver.VehicleAction_VehicleControlWindowAction{ - VehicleControlWindowAction: &carserver.VehicleControlWindowAction{ - Action: &carserver.VehicleControlWindowAction_Vent{ - Vent: &carserver.Void{}, - }, - }, - }, - }, - }) -} - -func (v *Vehicle) ChargePortClose(ctx context.Context) error { - return v.executeCarServerAction(ctx, - &carserver.Action_VehicleAction{ - VehicleAction: &carserver.VehicleAction{ - VehicleActionMsg: &carserver.VehicleAction_ChargePortDoorClose{ - ChargePortDoorClose: &carserver.ChargePortDoorClose{}, - }, - }, - }) -} - -func (v *Vehicle) ChargePortOpen(ctx context.Context) error { - return v.executeCarServerAction(ctx, - &carserver.Action_VehicleAction{ - VehicleAction: &carserver.VehicleAction{ - VehicleActionMsg: &carserver.VehicleAction_ChargePortDoorOpen{ - ChargePortDoorOpen: &carserver.ChargePortDoorOpen{}, - }, - }, - }) -} - -// OpenTonneau opens a Cybetruck's tonneau. Has no effect on other vehicles. -func (v *Vehicle) OpenTonneau(ctx context.Context) error { - return v.executeClosureAction(ctx, vcsec.ClosureMoveType_E_CLOSURE_MOVE_TYPE_OPEN, ClosureTonneau) -} - -// CloseTonneau closes a Cybetruck's tonneau. Has no effect on other vehicles. -func (v *Vehicle) CloseTonneau(ctx context.Context) error { - return v.executeClosureAction(ctx, vcsec.ClosureMoveType_E_CLOSURE_MOVE_TYPE_CLOSE, ClosureTonneau) -} - -// StopTonneau tells a Cybetruck to stop moving its tonneau. Has no effect on other vehicles. -func (v *Vehicle) StopTonneau(ctx context.Context) error { - return v.executeClosureAction(ctx, vcsec.ClosureMoveType_E_CLOSURE_MOVE_TYPE_STOP, ClosureTonneau) -} diff --git a/pkg/vehicle/charge.go b/pkg/vehicle/charge.go deleted file mode 100644 index a00f32c..0000000 --- a/pkg/vehicle/charge.go +++ /dev/null @@ -1,299 +0,0 @@ -// File implements commands related to vehicle charging. - -package vehicle - -import ( - "context" - "fmt" - "time" - - carserver "github.com/teslamotors/vehicle-command/pkg/protocol/protobuf/carserver" -) - -type ChargingPolicy int - -const ( - ChargingPolicyOff ChargingPolicy = iota - ChargingPolicyAllDays - ChargingPolicyWeekdays -) - -type ChargeSchedule = carserver.ChargeSchedule - -type PreconditionSchedule = carserver.PreconditionSchedule - -func (v *Vehicle) AddChargeSchedule(ctx context.Context, schedule *ChargeSchedule) error { - return v.executeCarServerAction(ctx, - &carserver.Action_VehicleAction{ - VehicleAction: &carserver.VehicleAction{ - VehicleActionMsg: &carserver.VehicleAction_AddChargeScheduleAction{ - AddChargeScheduleAction: schedule, - }, - }, - }) -} - -func (v *Vehicle) RemoveChargeSchedule(ctx context.Context, id uint64) error { - return v.executeCarServerAction(ctx, - &carserver.Action_VehicleAction{ - VehicleAction: &carserver.VehicleAction{ - VehicleActionMsg: &carserver.VehicleAction_RemoveChargeScheduleAction{ - RemoveChargeScheduleAction: &carserver.RemoveChargeScheduleAction{ - Id: id, - }, - }, - }, - }) -} - -func (v *Vehicle) BatchRemoveChargeSchedules(ctx context.Context, home, work, other bool) error { - return v.executeCarServerAction(ctx, - &carserver.Action_VehicleAction{ - VehicleAction: &carserver.VehicleAction{ - VehicleActionMsg: &carserver.VehicleAction_BatchRemoveChargeSchedulesAction{ - BatchRemoveChargeSchedulesAction: &carserver.BatchRemoveChargeSchedulesAction{ - Home: home, - Work: work, - Other: other, - }, - }, - }, - }) -} - -func (v *Vehicle) AddPreconditionSchedule(ctx context.Context, schedule *PreconditionSchedule) error { - return v.executeCarServerAction(ctx, - &carserver.Action_VehicleAction{ - VehicleAction: &carserver.VehicleAction{ - VehicleActionMsg: &carserver.VehicleAction_AddPreconditionScheduleAction{ - AddPreconditionScheduleAction: schedule, - }, - }, - }) -} - -func (v *Vehicle) RemovePreconditionSchedule(ctx context.Context, id uint64) error { - return v.executeCarServerAction(ctx, - &carserver.Action_VehicleAction{ - VehicleAction: &carserver.VehicleAction{ - VehicleActionMsg: &carserver.VehicleAction_RemovePreconditionScheduleAction{ - RemovePreconditionScheduleAction: &carserver.RemovePreconditionScheduleAction{ - Id: id, - }, - }, - }, - }) -} - -func (v *Vehicle) BatchRemovePreconditionSchedules(ctx context.Context, home, work, other bool) error { - return v.executeCarServerAction(ctx, - &carserver.Action_VehicleAction{ - VehicleAction: &carserver.VehicleAction{ - VehicleActionMsg: &carserver.VehicleAction_BatchRemovePreconditionSchedulesAction{ - BatchRemovePreconditionSchedulesAction: &carserver.BatchRemovePreconditionSchedulesAction{ - Home: home, - Work: work, - Other: other, - }, - }, - }, - }) -} - -func (v *Vehicle) ChangeChargeLimit(ctx context.Context, chargeLimitPercent int32) error { - return v.executeCarServerAction(ctx, - &carserver.Action_VehicleAction{ - VehicleAction: &carserver.VehicleAction{ - VehicleActionMsg: &carserver.VehicleAction_ChargingSetLimitAction{ - ChargingSetLimitAction: &carserver.ChargingSetLimitAction{ - Percent: chargeLimitPercent, - }, - }, - }, - }) -} - -func (v *Vehicle) ChargeStart(ctx context.Context) error { - return v.executeCarServerAction(ctx, - &carserver.Action_VehicleAction{ - VehicleAction: &carserver.VehicleAction{ - VehicleActionMsg: &carserver.VehicleAction_ChargingStartStopAction{ - ChargingStartStopAction: &carserver.ChargingStartStopAction{ - ChargingAction: &carserver.ChargingStartStopAction_Start{ - Start: &carserver.Void{}, - }, - }, - }, - }, - }) -} - -func (v *Vehicle) ChargeStop(ctx context.Context) error { - return v.executeCarServerAction(ctx, - &carserver.Action_VehicleAction{ - VehicleAction: &carserver.VehicleAction{ - VehicleActionMsg: &carserver.VehicleAction_ChargingStartStopAction{ - ChargingStartStopAction: &carserver.ChargingStartStopAction{ - ChargingAction: &carserver.ChargingStartStopAction_Stop{ - Stop: &carserver.Void{}, - }, - }, - }, - }, - }) -} - -func (v *Vehicle) ChargeMaxRange(ctx context.Context) error { - return v.executeCarServerAction(ctx, - &carserver.Action_VehicleAction{ - VehicleAction: &carserver.VehicleAction{ - VehicleActionMsg: &carserver.VehicleAction_ChargingStartStopAction{ - ChargingStartStopAction: &carserver.ChargingStartStopAction{ - ChargingAction: &carserver.ChargingStartStopAction_StartMaxRange{ - StartMaxRange: &carserver.Void{}, - }, - }, - }, - }, - }) -} - -func (v *Vehicle) SetChargingAmps(ctx context.Context, amps int32) error { - return v.executeCarServerAction(ctx, - &carserver.Action_VehicleAction{ - VehicleAction: &carserver.VehicleAction{ - VehicleActionMsg: &carserver.VehicleAction_SetChargingAmpsAction{ - SetChargingAmpsAction: &carserver.SetChargingAmpsAction{ - ChargingAmps: amps, - }, - }, - }, - }) - -} - -func (v *Vehicle) ChargeStandardRange(ctx context.Context) error { - return v.executeCarServerAction(ctx, - &carserver.Action_VehicleAction{ - VehicleAction: &carserver.VehicleAction{ - VehicleActionMsg: &carserver.VehicleAction_ChargingStartStopAction{ - ChargingStartStopAction: &carserver.ChargingStartStopAction{ - ChargingAction: &carserver.ChargingStartStopAction_StartStandard{ - StartStandard: &carserver.Void{}, - }, - }, - }, - }, - }) -} - -func (v *Vehicle) OpenChargePort(ctx context.Context) error { - return v.executeCarServerAction(ctx, - &carserver.Action_VehicleAction{ - VehicleAction: &carserver.VehicleAction{ - VehicleActionMsg: &carserver.VehicleAction_ChargePortDoorOpen{ - ChargePortDoorOpen: &carserver.ChargePortDoorOpen{}, - }, - }, - }) -} - -func (v *Vehicle) CloseChargePort(ctx context.Context) error { - return v.executeCarServerAction(ctx, - &carserver.Action_VehicleAction{ - VehicleAction: &carserver.VehicleAction{ - VehicleActionMsg: &carserver.VehicleAction_ChargePortDoorClose{ - ChargePortDoorClose: &carserver.ChargePortDoorClose{}, - }, - }, - }) -} - -// ScheduledDeparture tells the vehicle to charge based on an expected departure time. -// -// Set departAt and offPeakEndTime relative to midnight. -func (v *Vehicle) ScheduleDeparture(ctx context.Context, departAt, offPeakEndTime time.Duration, preconditioning, offpeak ChargingPolicy) error { - if departAt < 0 || departAt > 24*time.Hour { - return fmt.Errorf("invalid departure time") - } - var preconditionProto *carserver.PreconditioningTimes - switch preconditioning { - case ChargingPolicyOff: - case ChargingPolicyAllDays: - preconditionProto = &carserver.PreconditioningTimes{ - Times: &carserver.PreconditioningTimes_AllWeek{ - AllWeek: &carserver.Void{}, - }, - } - case ChargingPolicyWeekdays: - preconditionProto = &carserver.PreconditioningTimes{ - Times: &carserver.PreconditioningTimes_Weekdays{ - Weekdays: &carserver.Void{}, - }, - } - } - - var offPeakProto *carserver.OffPeakChargingTimes - switch offpeak { - case ChargingPolicyOff: - case ChargingPolicyAllDays: - offPeakProto = &carserver.OffPeakChargingTimes{ - Times: &carserver.OffPeakChargingTimes_AllWeek{ - AllWeek: &carserver.Void{}, - }, - } - case ChargingPolicyWeekdays: - offPeakProto = &carserver.OffPeakChargingTimes{ - Times: &carserver.OffPeakChargingTimes_Weekdays{ - Weekdays: &carserver.Void{}, - }, - } - } - - return v.executeCarServerAction(ctx, - &carserver.Action_VehicleAction{ - VehicleAction: &carserver.VehicleAction{ - VehicleActionMsg: &carserver.VehicleAction_ScheduledDepartureAction{ - ScheduledDepartureAction: &carserver.ScheduledDepartureAction{ - Enabled: true, - DepartureTime: int32(departAt / time.Minute), - PreconditioningTimes: preconditionProto, - OffPeakChargingTimes: offPeakProto, - OffPeakHoursEndTime: int32(offPeakEndTime / time.Minute), - }, - }, - }, - }) -} - -// ScheduleCharging controls scheduled charging. To start charging at 2:00 AM every day, for -// example, set timeAfterMidnight to 2*time.Hour. -// -// See the Owner's Manual for more information. -func (v *Vehicle) ScheduleCharging(ctx context.Context, enabled bool, timeAfterMidnight time.Duration) error { - minutesFromMidnight := int32(timeAfterMidnight / time.Minute) - return v.executeCarServerAction(ctx, - &carserver.Action_VehicleAction{ - VehicleAction: &carserver.VehicleAction{ - VehicleActionMsg: &carserver.VehicleAction_ScheduledChargingAction{ - ScheduledChargingAction: &carserver.ScheduledChargingAction{ - Enabled: enabled, - ChargingTime: minutesFromMidnight, - }, - }, - }, - }) -} - -func (v *Vehicle) ClearScheduledDeparture(ctx context.Context) error { - return v.executeCarServerAction(ctx, - &carserver.Action_VehicleAction{ - VehicleAction: &carserver.VehicleAction{ - VehicleActionMsg: &carserver.VehicleAction_ScheduledDepartureAction{ - ScheduledDepartureAction: &carserver.ScheduledDepartureAction{ - Enabled: false, - }, - }, - }, - }) -} diff --git a/pkg/vehicle/climate.go b/pkg/vehicle/climate.go deleted file mode 100644 index 8aeecda..0000000 --- a/pkg/vehicle/climate.go +++ /dev/null @@ -1,271 +0,0 @@ -package vehicle - -import ( - "context" - "fmt" - - carserver "github.com/teslamotors/vehicle-command/pkg/protocol/protobuf/carserver" -) - -// SetSeatCooler sets seat cooling level. -func (v *Vehicle) SetSeatCooler(ctx context.Context, level Level, seat SeatPosition) error { - // The protobuf index starts at 0 for unknown, we want to start with 0 for off - seatMap := map[SeatPosition]carserver.HvacSeatCoolerActions_HvacSeatCoolerPosition_E{ - SeatFrontLeft: carserver.HvacSeatCoolerActions_HvacSeatCoolerPosition_FrontLeft, - SeatFrontRight: carserver.HvacSeatCoolerActions_HvacSeatCoolerPosition_FrontRight, - } - protoSeat, ok := seatMap[seat] - if !ok { - return fmt.Errorf("invalid seat position") - } - return v.executeCarServerAction(ctx, - &carserver.Action_VehicleAction{ - VehicleAction: &carserver.VehicleAction{ - VehicleActionMsg: &carserver.VehicleAction_HvacSeatCoolerActions{ - HvacSeatCoolerActions: &carserver.HvacSeatCoolerActions{ - HvacSeatCoolerAction: []*carserver.HvacSeatCoolerActions_HvacSeatCoolerAction{ - &carserver.HvacSeatCoolerActions_HvacSeatCoolerAction{ - SeatCoolerLevel: carserver.HvacSeatCoolerActions_HvacSeatCoolerLevel_E(level + 1), - SeatPosition: protoSeat, - }, - }, - }, - }, - }, - }) -} - -func (v *Vehicle) ClimateOn(ctx context.Context) error { - return v.executeCarServerAction(ctx, - &carserver.Action_VehicleAction{ - VehicleAction: &carserver.VehicleAction{ - VehicleActionMsg: &carserver.VehicleAction_HvacAutoAction{ - HvacAutoAction: &carserver.HvacAutoAction{ - PowerOn: true, - }, - }, - }, - }) -} - -func (v *Vehicle) ClimateOff(ctx context.Context) error { - return v.executeCarServerAction(ctx, - &carserver.Action_VehicleAction{ - VehicleAction: &carserver.VehicleAction{ - VehicleActionMsg: &carserver.VehicleAction_HvacAutoAction{ - HvacAutoAction: &carserver.HvacAutoAction{ - PowerOn: false, - }, - }, - }, - }) -} - -func (v *Vehicle) AutoSeatAndClimate(ctx context.Context, positions []SeatPosition, enabled bool) error { - lookup := map[SeatPosition]carserver.AutoSeatClimateAction_AutoSeatPosition_E{ - SeatUnknown: carserver.AutoSeatClimateAction_AutoSeatPosition_Unknown, - SeatFrontLeft: carserver.AutoSeatClimateAction_AutoSeatPosition_FrontLeft, - SeatFrontRight: carserver.AutoSeatClimateAction_AutoSeatPosition_FrontRight, - } - var seats []*carserver.AutoSeatClimateAction_CarSeat - for _, pos := range positions { - if protoPos, ok := lookup[pos]; ok { - seats = append(seats, &carserver.AutoSeatClimateAction_CarSeat{On: enabled, SeatPosition: protoPos}) - } - } - return v.executeCarServerAction(ctx, - &carserver.Action_VehicleAction{ - VehicleAction: &carserver.VehicleAction{ - VehicleActionMsg: &carserver.VehicleAction_AutoSeatClimateAction{ - AutoSeatClimateAction: &carserver.AutoSeatClimateAction{ - Carseat: seats, - }, - }, - }, - }) -} - -func (v *Vehicle) ChangeClimateTemp(ctx context.Context, driverCelsius float32, passengerCelsius float32) error { - return v.executeCarServerAction(ctx, - &carserver.Action_VehicleAction{ - VehicleAction: &carserver.VehicleAction{ - VehicleActionMsg: &carserver.VehicleAction_HvacTemperatureAdjustmentAction{ - HvacTemperatureAdjustmentAction: &carserver.HvacTemperatureAdjustmentAction{ - DriverTempCelsius: driverCelsius, - PassengerTempCelsius: passengerCelsius, - Level: &carserver.HvacTemperatureAdjustmentAction_Temperature{ - Type: &carserver.HvacTemperatureAdjustmentAction_Temperature_TEMP_MAX{}, - }, - }, - }, - }, - }) -} - -// The seat positions defined in the protobuf sources are each independent Void messages instead of -// enumerated values. The autogenerated protobuf code doesn't export the interface that lets us -// declare or access an interface that includes them collectively. The following functions allow us -// to expose a single enumerated type to library clients. - -func (s SeatPosition) addToHeaterAction(action *carserver.HvacSeatHeaterActions_HvacSeatHeaterAction) { - switch s { - case SeatFrontLeft: - action.SeatPosition = &carserver.HvacSeatHeaterActions_HvacSeatHeaterAction_CAR_SEAT_FRONT_LEFT{} - case SeatFrontRight: - action.SeatPosition = &carserver.HvacSeatHeaterActions_HvacSeatHeaterAction_CAR_SEAT_FRONT_RIGHT{} - case SeatSecondRowLeft: - action.SeatPosition = &carserver.HvacSeatHeaterActions_HvacSeatHeaterAction_CAR_SEAT_REAR_LEFT{} - case SeatSecondRowLeftBack: - action.SeatPosition = &carserver.HvacSeatHeaterActions_HvacSeatHeaterAction_CAR_SEAT_REAR_LEFT_BACK{} - case SeatSecondRowCenter: - action.SeatPosition = &carserver.HvacSeatHeaterActions_HvacSeatHeaterAction_CAR_SEAT_REAR_CENTER{} - case SeatSecondRowRight: - action.SeatPosition = &carserver.HvacSeatHeaterActions_HvacSeatHeaterAction_CAR_SEAT_REAR_RIGHT{} - case SeatSecondRowRightBack: - action.SeatPosition = &carserver.HvacSeatHeaterActions_HvacSeatHeaterAction_CAR_SEAT_REAR_RIGHT_BACK{} - case SeatThirdRowLeft: - action.SeatPosition = &carserver.HvacSeatHeaterActions_HvacSeatHeaterAction_CAR_SEAT_THIRD_ROW_LEFT{} - case SeatThirdRowRight: - action.SeatPosition = &carserver.HvacSeatHeaterActions_HvacSeatHeaterAction_CAR_SEAT_THIRD_ROW_RIGHT{} - default: - action.SeatPosition = &carserver.HvacSeatHeaterActions_HvacSeatHeaterAction_CAR_SEAT_UNKNOWN{} - } -} - -type Level int - -const ( - LevelOff Level = iota - LevelLow - LevelMed - LevelHigh -) - -func (s Level) addToHeaterAction(action *carserver.HvacSeatHeaterActions_HvacSeatHeaterAction) { - switch s { - case LevelOff: - action.SeatHeaterLevel = &carserver.HvacSeatHeaterActions_HvacSeatHeaterAction_SEAT_HEATER_OFF{} - case LevelLow: - action.SeatHeaterLevel = &carserver.HvacSeatHeaterActions_HvacSeatHeaterAction_SEAT_HEATER_LOW{} - case LevelMed: - action.SeatHeaterLevel = &carserver.HvacSeatHeaterActions_HvacSeatHeaterAction_SEAT_HEATER_MED{} - case LevelHigh: - action.SeatHeaterLevel = &carserver.HvacSeatHeaterActions_HvacSeatHeaterAction_SEAT_HEATER_HIGH{} - default: - action.SeatHeaterLevel = &carserver.HvacSeatHeaterActions_HvacSeatHeaterAction_SEAT_HEATER_UNKNOWN{} - } -} - -func (v *Vehicle) SetSeatHeater(ctx context.Context, levels map[SeatPosition]Level) error { - var actions []*carserver.HvacSeatHeaterActions_HvacSeatHeaterAction - - for position, level := range levels { - action := new(carserver.HvacSeatHeaterActions_HvacSeatHeaterAction) - level.addToHeaterAction(action) - position.addToHeaterAction(action) - actions = append(actions, action) - } - - return v.executeCarServerAction(ctx, - &carserver.Action_VehicleAction{ - VehicleAction: &carserver.VehicleAction{ - VehicleActionMsg: &carserver.VehicleAction_HvacSeatHeaterActions{ - HvacSeatHeaterActions: &carserver.HvacSeatHeaterActions{ - HvacSeatHeaterAction: actions, - }, - }, - }, - }) -} - -func (v *Vehicle) SetSteeringWheelHeater(ctx context.Context, enabled bool) error { - return v.executeCarServerAction(ctx, - &carserver.Action_VehicleAction{ - VehicleAction: &carserver.VehicleAction{ - VehicleActionMsg: &carserver.VehicleAction_HvacSteeringWheelHeaterAction{ - HvacSteeringWheelHeaterAction: &carserver.HvacSteeringWheelHeaterAction{ - PowerOn: enabled, - }, - }, - }, - }) -} - -func (v *Vehicle) SetPreconditioningMax(ctx context.Context, enabled bool, manualOverride bool) error { - return v.executeCarServerAction(ctx, - &carserver.Action_VehicleAction{ - VehicleAction: &carserver.VehicleAction{ - VehicleActionMsg: &carserver.VehicleAction_HvacSetPreconditioningMaxAction{ - HvacSetPreconditioningMaxAction: &carserver.HvacSetPreconditioningMaxAction{ - On: enabled, - ManualOverride: manualOverride, - }, - }, - }, - }) -} - -func (v *Vehicle) SetBioweaponDefenseMode(ctx context.Context, enabled bool, manualOverride bool) error { - return v.executeCarServerAction(ctx, - &carserver.Action_VehicleAction{ - VehicleAction: &carserver.VehicleAction{ - VehicleActionMsg: &carserver.VehicleAction_HvacBioweaponModeAction{ - HvacBioweaponModeAction: &carserver.HvacBioweaponModeAction{ - On: enabled, - ManualOverride: manualOverride, - }, - }, - }, - }) - -} - -func (v *Vehicle) SetCabinOverheatProtection(ctx context.Context, enabled bool, fanOnly bool) error { - return v.executeCarServerAction(ctx, - &carserver.Action_VehicleAction{ - VehicleAction: &carserver.VehicleAction{ - VehicleActionMsg: &carserver.VehicleAction_SetCabinOverheatProtectionAction{ - SetCabinOverheatProtectionAction: &carserver.SetCabinOverheatProtectionAction{ - On: enabled, - FanOnly: fanOnly, - }, - }, - }, - }) -} - -func (v *Vehicle) SetCabinOverheatProtectionTemperature(ctx context.Context, level Level) error { - return v.executeCarServerAction(ctx, - &carserver.Action_VehicleAction{ - VehicleAction: &carserver.VehicleAction{ - VehicleActionMsg: &carserver.VehicleAction_SetCopTempAction{ - SetCopTempAction: &carserver.SetCopTempAction{ - CopActivationTemp: carserver.ClimateState_CopActivationTemp(level), - }, - }, - }, - }) -} - -type ClimateKeeperMode = carserver.HvacClimateKeeperAction_ClimateKeeperAction_E - -const ( - ClimateKeeperModeOff = carserver.HvacClimateKeeperAction_ClimateKeeperAction_Off - ClimateKeeperModeOn = carserver.HvacClimateKeeperAction_ClimateKeeperAction_On - ClimateKeeperModeDog = carserver.HvacClimateKeeperAction_ClimateKeeperAction_Dog - ClimateKeeperModeCamp = carserver.HvacClimateKeeperAction_ClimateKeeperAction_Camp -) - -func (v *Vehicle) SetClimateKeeperMode(ctx context.Context, mode ClimateKeeperMode, override bool) error { - return v.executeCarServerAction(ctx, - &carserver.Action_VehicleAction{ - VehicleAction: &carserver.VehicleAction{ - VehicleActionMsg: &carserver.VehicleAction_HvacClimateKeeperAction{ - HvacClimateKeeperAction: &carserver.HvacClimateKeeperAction{ - ClimateKeeperAction: mode, - ManualOverride: override, - }, - }, - }, - }) -} diff --git a/pkg/vehicle/infotainment.go b/pkg/vehicle/infotainment.go deleted file mode 100644 index 568ab48..0000000 --- a/pkg/vehicle/infotainment.go +++ /dev/null @@ -1,170 +0,0 @@ -// File implements helper functions for commands that terminate on infotainment. -// -// File also contains misc. commands that don't fall cleanly into more specific categories. - -package vehicle - -import ( - "context" - "fmt" - "time" - - "google.golang.org/protobuf/proto" - - "github.com/teslamotors/vehicle-command/pkg/protocol" - carserver "github.com/teslamotors/vehicle-command/pkg/protocol/protobuf/carserver" - universal "github.com/teslamotors/vehicle-command/pkg/protocol/protobuf/universalmessage" -) - -func (v *Vehicle) getCarServerResponse(ctx context.Context, action *carserver.Action_VehicleAction) (*carserver.Response, error) { - payload := carserver.Action{ - ActionMsg: action, - } - encodedPayload, err := proto.Marshal(&payload) - if err != nil { - return nil, err - } - responsePayload, err := v.Send(ctx, universal.Domain_DOMAIN_INFOTAINMENT, encodedPayload, v.authMethod) - if err != nil { - return nil, err - } - - var response carserver.Response - if err := proto.Unmarshal(responsePayload, &response); err != nil { - return nil, &protocol.CommandError{Err: fmt.Errorf("unable to parse vehicle response: %w", err), PossibleSuccess: true, PossibleTemporary: false} - } - - if response.GetActionStatus().GetResult() == carserver.OperationStatus_E_OPERATIONSTATUS_ERROR { - description := response.GetActionStatus().GetResultReason().GetPlainText() - if description == "" { - description = "unspecified error" - } - return nil, &protocol.NominalError{Details: protocol.NewError("car could not execute command: "+description, false, false)} - } - return &response, nil -} - -func (v *Vehicle) executeCarServerAction(ctx context.Context, action *carserver.Action_VehicleAction) error { - _, err := v.getCarServerResponse(ctx, action) - return err -} - -// Ping sends an authenticated "no-op" command to the vehicle. -// If the method returns an non-nil error, then the vehicle is online and recognizes the client's -// public key. -// -// The error is a [protocol.RoutableMessageError] then the vehicle is online, but rejected the command -// for some other reason (for example, it may not recognize the client's public key or may have -// mobile access disabled). -func (v *Vehicle) Ping(ctx context.Context) error { - return v.executeCarServerAction(ctx, - &carserver.Action_VehicleAction{ - VehicleAction: &carserver.VehicleAction{ - VehicleActionMsg: &carserver.VehicleAction_Ping{ - Ping: &carserver.Ping{ - PingId: 1, // Responses are disambiguated on the protocol later - }, - }, - }, - }) -} - -// SetVolume to a value between 0 and 10. -func (v *Vehicle) SetVolume(ctx context.Context, volume float32) error { - if volume < 0 || volume > 10 { - return fmt.Errorf("invalid volume (should be in [0, 10])") - } - return v.executeCarServerAction(ctx, - &carserver.Action_VehicleAction{ - VehicleAction: &carserver.VehicleAction{ - VehicleActionMsg: &carserver.VehicleAction_MediaUpdateVolume{ - MediaUpdateVolume: &carserver.MediaUpdateVolume{ - MediaVolume: &carserver.MediaUpdateVolume_VolumeAbsoluteFloat{ - VolumeAbsoluteFloat: volume, - }, - }, - }, - }, - }) -} - -func (v *Vehicle) ToggleMediaPlayback(ctx context.Context) error { - return v.executeCarServerAction(ctx, - &carserver.Action_VehicleAction{ - VehicleAction: &carserver.VehicleAction{ - VehicleActionMsg: &carserver.VehicleAction_MediaPlayAction{ - MediaPlayAction: &carserver.MediaPlayAction{}, - }, - }, - }) -} - -func (v *Vehicle) ScheduleSoftwareUpdate(ctx context.Context, delay time.Duration) error { - seconds := int32(delay / time.Second) - return v.executeCarServerAction(ctx, - &carserver.Action_VehicleAction{ - VehicleAction: &carserver.VehicleAction{ - VehicleActionMsg: &carserver.VehicleAction_VehicleControlScheduleSoftwareUpdateAction{ - VehicleControlScheduleSoftwareUpdateAction: &carserver.VehicleControlScheduleSoftwareUpdateAction{ - OffsetSec: seconds, - }, - }, - }, - }) -} - -func (v *Vehicle) CancelSoftwareUpdate(ctx context.Context) error { - return v.executeCarServerAction(ctx, - &carserver.Action_VehicleAction{ - VehicleAction: &carserver.VehicleAction{ - VehicleActionMsg: &carserver.VehicleAction_VehicleControlCancelSoftwareUpdateAction{ - VehicleControlCancelSoftwareUpdateAction: &carserver.VehicleControlCancelSoftwareUpdateAction{}, - }, - }, - }) -} - -type SeatPosition int64 - -// Enumerated type for seats. Values with the Back suffix are used for seat heater/cooler commands, -// and refer to the backrest. Backrest heaters are only available on some Model S vehicles. -const ( - SeatUnknown SeatPosition = iota - SeatFrontLeft - SeatFrontRight - SeatSecondRowLeft - SeatSecondRowLeftBack - SeatSecondRowCenter - SeatSecondRowRight - SeatSecondRowRightBack - SeatThirdRowLeft - SeatThirdRowRight -) - -func (v *Vehicle) GetNearbyCharging(ctx context.Context) error { - return v.executeCarServerAction(ctx, - &carserver.Action_VehicleAction{ - VehicleAction: &carserver.VehicleAction{ - VehicleActionMsg: &carserver.VehicleAction_GetNearbyChargingSites{ - GetNearbyChargingSites: &carserver.GetNearbyChargingSites{ - IncludeMetaData: true, - Radius: 200, - Count: 10, - }, - }, - }, - }) -} - -func (v *Vehicle) SetVehicleName(ctx context.Context, name string) error { - return v.executeCarServerAction(ctx, - &carserver.Action_VehicleAction{ - VehicleAction: &carserver.VehicleAction{ - VehicleActionMsg: &carserver.VehicleAction_SetVehicleNameAction{ - SetVehicleNameAction: &carserver.SetVehicleNameAction{ - VehicleName: name, - }, - }, - }, - }) -} diff --git a/pkg/vehicle/security.go b/pkg/vehicle/security.go index c5716e6..fe89f44 100644 --- a/pkg/vehicle/security.go +++ b/pkg/vehicle/security.go @@ -8,174 +8,10 @@ import ( "github.com/teslamotors/vehicle-command/pkg/connector" "github.com/teslamotors/vehicle-command/pkg/protocol" - carserver "github.com/teslamotors/vehicle-command/pkg/protocol/protobuf/carserver" "github.com/teslamotors/vehicle-command/pkg/protocol/protobuf/keys" "github.com/teslamotors/vehicle-command/pkg/protocol/protobuf/vcsec" ) -func (v *Vehicle) SetValetMode(ctx context.Context, on bool, valetPassword string) error { - return v.executeCarServerAction(ctx, - &carserver.Action_VehicleAction{ - VehicleAction: &carserver.VehicleAction{ - VehicleActionMsg: &carserver.VehicleAction_VehicleControlSetValetModeAction{ - VehicleControlSetValetModeAction: &carserver.VehicleControlSetValetModeAction{ - On: on, - Password: valetPassword, - }, - }, - }, - }) -} - -func (v *Vehicle) ResetValetPin(ctx context.Context) error { - return v.executeCarServerAction(ctx, - &carserver.Action_VehicleAction{ - VehicleAction: &carserver.VehicleAction{ - VehicleActionMsg: &carserver.VehicleAction_VehicleControlResetValetPinAction{ - VehicleControlResetValetPinAction: &carserver.VehicleControlResetValetPinAction{}, - }, - }, - }) -} - -// ResetPIN clears the saved PIN. You must disable PIN to drive before clearing the PIN. This allows -// setting a new PIN using SetPINToDrive. -func (v *Vehicle) ResetPIN(ctx context.Context) error { - return v.executeCarServerAction(ctx, - &carserver.Action_VehicleAction{ - VehicleAction: &carserver.VehicleAction{ - VehicleActionMsg: &carserver.VehicleAction_VehicleControlResetPinToDriveAction{ - VehicleControlResetPinToDriveAction: &carserver.VehicleControlResetPinToDriveAction{}, - }, - }, - }) -} - -func (v *Vehicle) ActivateSpeedLimit(ctx context.Context, speedLimitPin string) error { - return v.executeCarServerAction(ctx, - &carserver.Action_VehicleAction{ - VehicleAction: &carserver.VehicleAction{ - VehicleActionMsg: &carserver.VehicleAction_DrivingSpeedLimitAction{ - DrivingSpeedLimitAction: &carserver.DrivingSpeedLimitAction{ - Activate: true, - Pin: speedLimitPin, - }, - }, - }, - }) -} - -func (v *Vehicle) DeactivateSpeedLimit(ctx context.Context, speedLimitPin string) error { - return v.executeCarServerAction(ctx, - &carserver.Action_VehicleAction{ - VehicleAction: &carserver.VehicleAction{ - VehicleActionMsg: &carserver.VehicleAction_DrivingSpeedLimitAction{ - DrivingSpeedLimitAction: &carserver.DrivingSpeedLimitAction{ - Activate: false, - Pin: speedLimitPin, - }, - }, - }, - }) -} - -func (v *Vehicle) SpeedLimitSetLimitMPH(ctx context.Context, speedLimitMPH float64) error { - return v.executeCarServerAction(ctx, - &carserver.Action_VehicleAction{ - VehicleAction: &carserver.VehicleAction{ - VehicleActionMsg: &carserver.VehicleAction_DrivingSetSpeedLimitAction{ - DrivingSetSpeedLimitAction: &carserver.DrivingSetSpeedLimitAction{ - LimitMph: speedLimitMPH, - }, - }, - }, - }) -} - -func (v *Vehicle) ClearSpeedLimitPIN(ctx context.Context, speedLimitPin string) error { - return v.executeCarServerAction(ctx, - &carserver.Action_VehicleAction{ - VehicleAction: &carserver.VehicleAction{ - VehicleActionMsg: &carserver.VehicleAction_DrivingClearSpeedLimitPinAction{ - DrivingClearSpeedLimitPinAction: &carserver.DrivingClearSpeedLimitPinAction{ - Pin: speedLimitPin, - }, - }, - }, - }) -} - -func (v *Vehicle) SetSentryMode(ctx context.Context, state bool) error { - return v.executeCarServerAction(ctx, - &carserver.Action_VehicleAction{ - VehicleAction: &carserver.VehicleAction{ - VehicleActionMsg: &carserver.VehicleAction_VehicleControlSetSentryModeAction{ - VehicleControlSetSentryModeAction: &carserver.VehicleControlSetSentryModeAction{ - On: state, - }, - }, - }, - }) -} - -// SetGuestMode enables or disables the vehicle's guest mode. -// -// We recommend users avoid this command unless they are managing a fleet of vehicles and understand -// the implications of enabling the mode. See official API documentation at -// https://developer.tesla.com/docs/fleet-api/endpoints/vehicle-commands#guest-mode -func (v *Vehicle) SetGuestMode(ctx context.Context, enabled bool) error { - return v.executeCarServerAction(ctx, - &carserver.Action_VehicleAction{ - VehicleAction: &carserver.VehicleAction{ - VehicleActionMsg: &carserver.VehicleAction_GuestModeAction{ - GuestModeAction: &carserver.VehicleState_GuestMode{ - GuestModeActive: enabled, - }, - }, - }, - }) -} - -// SetPINToDrive controls whether the PIN to Drive feature is enabled or not. It is also used to set -// the PIN. -// -// Once a PIN is set, the vehicle remembers its value even when PIN to Drive is disabled and -// discards any new PIN provided using this method. To change an existing PIN, first call -// v.ResetPIN. -func (v *Vehicle) SetPINToDrive(ctx context.Context, enabled bool, pin string) error { - if _, ok := v.conn.(connector.FleetAPIConnector); !ok { - return protocol.ErrRequiresEncryption - } - - return v.executeCarServerAction(ctx, - &carserver.Action_VehicleAction{ - VehicleAction: &carserver.VehicleAction{ - VehicleActionMsg: &carserver.VehicleAction_VehicleControlSetPinToDriveAction{ - VehicleControlSetPinToDriveAction: &carserver.VehicleControlSetPinToDriveAction{ - On: enabled, - Password: pin, - }, - }, - }, - }) -} - -func (v *Vehicle) TriggerHomelink(ctx context.Context, latitude float32, longitude float32) error { - return v.executeCarServerAction(ctx, - &carserver.Action_VehicleAction{ - VehicleAction: &carserver.VehicleAction{ - VehicleActionMsg: &carserver.VehicleAction_VehicleControlTriggerHomelinkAction{ - VehicleControlTriggerHomelinkAction: &carserver.VehicleControlTriggerHomelinkAction{ - Location: &carserver.LatLong{ - Latitude: latitude, - Longitude: longitude, - }, - }, - }, - }, - }) -} - // AddKey adds a public key to the vehicle's whitelist. If isOwner is true, the new key can // authorize changes to vehicle access controls, such as adding/removing other keys. func (v *Vehicle) AddKey(ctx context.Context, publicKey *ecdh.PublicKey, isOwner bool, formFactor vcsec.KeyFormFactor) error { @@ -239,14 +75,6 @@ func (v *Vehicle) KeyInfoBySlot(ctx context.Context, slot uint32) (*vcsec.Whitel return reply.GetWhitelistEntryInfo(), err } -func (v *Vehicle) Lock(ctx context.Context) error { - return v.executeRKEAction(ctx, vcsec.RKEAction_E_RKE_ACTION_LOCK) -} - -func (v *Vehicle) Unlock(ctx context.Context) error { - return v.executeRKEAction(ctx, vcsec.RKEAction_E_RKE_ACTION_UNLOCK) -} - // SendAddKeyRequest sends an add-key request to the vehicle over BLE. The user must approve the // request by tapping their NFC card on the center console and then confirming their intent on the // vehicle UI. @@ -294,14 +122,3 @@ func (v *Vehicle) SendAddKeyRequestWithRole(ctx context.Context, publicKey *ecdh } return v.conn.Send(ctx, encodedEnvelope) } - -// EraseGuestData erases user data created while in Guest Mode. This command has no effect unless -// the vehicle is currently in Guest Mode. -func (v *Vehicle) EraseGuestData(ctx context.Context) error { - return v.executeCarServerAction(ctx, - &carserver.Action_VehicleAction{ - VehicleAction: &carserver.VehicleAction{ - VehicleActionMsg: &carserver.VehicleAction_EraseUserDataAction{}, - }, - }) -} diff --git a/pkg/vehicle/vcsec.go b/pkg/vehicle/vcsec.go index b1618cd..b0fe050 100644 --- a/pkg/vehicle/vcsec.go +++ b/pkg/vehicle/vcsec.go @@ -167,83 +167,3 @@ func addKeyPayload(publicKey *ecdh.PublicKey, role keys.Role, formFactor vcsec.K }, } } - -// executeRKEAction sends an RKE action command to the vehicle. (RKE originally -// referred to "Remote Keyless Entry" but now refers more generally to commands -// that can be sent by a keyfob). -func (v *Vehicle) executeRKEAction(ctx context.Context, action vcsec.RKEAction_E) error { - done := func(fromVCSEC *vcsec.FromVCSECMessage) (bool, error) { - if fromVCSEC.GetCommandStatus() == nil { - return true, nil - } - return false, nil - } - - payload := vcsec.UnsignedMessage{ - SubMessage: &vcsec.UnsignedMessage_RKEAction{ - RKEAction: action, - }, - } - encodedPayload, err := proto.Marshal(&payload) - if err != nil { - return err - } - - _, err = v.getVCSECResult(ctx, encodedPayload, v.authMethod, done) - return err -} - -// Not exported. Use v.Wakeup instead, which chooses the correct wake method based on available transport. -func (v *Vehicle) wakeupRKE(ctx context.Context) error { - return v.executeRKEAction(ctx, vcsec.RKEAction_E_RKE_ACTION_WAKE_VEHICLE) -} - -func (v *Vehicle) RemoteDrive(ctx context.Context) error { - return v.executeRKEAction(ctx, vcsec.RKEAction_E_RKE_ACTION_REMOTE_DRIVE) -} - -func (v *Vehicle) AutoSecureVehicle(ctx context.Context) error { - return v.executeRKEAction(ctx, vcsec.RKEAction_E_RKE_ACTION_AUTO_SECURE_VEHICLE) -} - -type Closure string - -const ( - ClosureTrunk Closure = "trunk" - ClosureFrunk Closure = "frunk" - ClosureTonneau Closure = "tonneau" -) - -func (v *Vehicle) executeClosureAction(ctx context.Context, action vcsec.ClosureMoveType_E, closure Closure) error { - done := func(fromVCSEC *vcsec.FromVCSECMessage) (bool, error) { - if fromVCSEC.GetCommandStatus() == nil { - return true, nil - } - return false, nil - } - - // Not all actions are meaningful for all closures. Exported methods restrict combinations. - var request vcsec.ClosureMoveRequest - switch closure { - case ClosureTrunk: - request.RearTrunk = action - case ClosureFrunk: - request.FrontTrunk = action - case ClosureTonneau: - request.Tonneau = action - } - - payload := vcsec.UnsignedMessage{ - SubMessage: &vcsec.UnsignedMessage_ClosureMoveRequest{ - ClosureMoveRequest: &request, - }, - } - - encodedPayload, err := proto.Marshal(&payload) - if err != nil { - return err - } - - _, err = v.getVCSECResult(ctx, encodedPayload, v.authMethod, done) - return err -} diff --git a/pkg/vehicle/vcsec_test.go b/pkg/vehicle/vcsec_test.go index 482d8a6..9b154d8 100644 --- a/pkg/vehicle/vcsec_test.go +++ b/pkg/vehicle/vcsec_test.go @@ -108,7 +108,6 @@ func TestNominalVSCECError(t *testing.T) { checkNominalError(t, vehicle.AddKey(ctx, testPublicKey(), true, 0), errCode) checkNominalError(t, vehicle.RemoveKey(ctx, testPublicKey()), errCode) - checkNominalError(t, vehicle.Lock(ctx), errCode) } func TestGibberishVCSECResponse(t *testing.T) { @@ -133,9 +132,6 @@ func TestGibberishVCSECResponse(t *testing.T) { if err := vehicle.RemoveKey(ctx, testPublicKey()); !errors.Is(err, protocol.ErrBadResponse) { t.Errorf("Unexpected error: %s", err) } - if err := vehicle.Lock(ctx); !errors.Is(err, protocol.ErrBadResponse) { - t.Errorf("Unexpected error: %s", err) - } } func (s *testSender) EnqueueVCSECBusy(t *testing.T) { diff --git a/pkg/vehicle/vehicle.go b/pkg/vehicle/vehicle.go index dc23c80..2652020 100644 --- a/pkg/vehicle/vehicle.go +++ b/pkg/vehicle/vehicle.go @@ -10,10 +10,10 @@ import ( "github.com/teslamotors/vehicle-command/internal/authentication" "github.com/teslamotors/vehicle-command/internal/dispatcher" + "github.com/teslamotors/vehicle-command/pkg/action" "github.com/teslamotors/vehicle-command/pkg/cache" "github.com/teslamotors/vehicle-command/pkg/connector" "github.com/teslamotors/vehicle-command/pkg/protocol" - "github.com/teslamotors/vehicle-command/pkg/protocol/protobuf/signatures" universal "github.com/teslamotors/vehicle-command/pkg/protocol/protobuf/universalmessage" ) @@ -255,7 +255,7 @@ func (v *Vehicle) Wakeup(ctx context.Context) error { if oapi, ok := v.conn.(connector.FleetAPIConnector); ok { return oapi.Wakeup(ctx) } else { - return v.wakeupRKE(ctx) + return v.ExecuteAction(ctx, action.WakeUp()) } } diff --git a/pkg/vehicle/vehicle_mock_test.go b/pkg/vehicle/vehicle_mock_test.go deleted file mode 100644 index 4594009..0000000 --- a/pkg/vehicle/vehicle_mock_test.go +++ /dev/null @@ -1,9 +0,0 @@ -package vehicle_test - -import "context" - -type MockVehicle struct{} - -func (v *MockVehicle) SetVolume(ctx context.Context, volume float32) error { - return nil -}