From 7fbcb326db4bdd62b133227b7075e35d0e64fca1 Mon Sep 17 00:00:00 2001 From: Lenart Kos Date: Sat, 18 Jan 2025 17:29:30 +0100 Subject: [PATCH 01/41] Abort processing command if BLE scan does not find the vehicle --- config/config.go | 14 +++++++++++++- internal/ble/control/control.go | 26 +++++++++++++++++++++++--- 2 files changed, 36 insertions(+), 4 deletions(-) diff --git a/config/config.go b/config/config.go index 576f13a..84346cc 100644 --- a/config/config.go +++ b/config/config.go @@ -13,6 +13,7 @@ var PrivateKeyFile = "key/private.pem" type Config struct { LogLevel string HttpListenAddress string + ScanTimeout int // Seconds to scan for BLE beacons during device scan (0 = max) CacheMaxAge int // Seconds to cache BLE responses } @@ -34,12 +35,22 @@ func LoadConfig() *Config { } log.Info("TeslaBleHttpProxy", "httpListenAddress", addr) + scanTimeout := os.Getenv("scanTimeout") + if scanTimeout == "" { + scanTimeout = "1" // default value + } + scanTimeoutInt, err := strconv.Atoi(scanTimeout) + if err != nil || scanTimeoutInt < 0 { + log.Error("Invalid scanTimeout value, using default (1)", "error", err) + scanTimeoutInt = 1 + } + cacheMaxAge := os.Getenv("cacheMaxAge") if cacheMaxAge == "" { cacheMaxAge = "0" // default value } cacheMaxAgeInt, err := strconv.Atoi(cacheMaxAge) - if err != nil { + if err != nil || cacheMaxAgeInt < 0 { log.Error("Invalid cacheMaxAge value, using default (0)", "error", err) cacheMaxAgeInt = 0 } @@ -47,6 +58,7 @@ func LoadConfig() *Config { return &Config{ LogLevel: envLogLevel, HttpListenAddress: addr, + ScanTimeout: scanTimeoutInt, CacheMaxAge: cacheMaxAgeInt, } } diff --git a/internal/ble/control/control.go b/internal/ble/control/control.go index f4dabe0..8ceafde 100644 --- a/internal/ble/control/control.go +++ b/internal/ble/control/control.go @@ -176,16 +176,36 @@ func (bc *BleControl) TryConnectToVehicle(ctx context.Context, firstCommand *com }() var err error - conn, err = ble.NewConnection(ctx, firstCommand.Vin) + log.Debug("scan for vehicle ...") + // Vehicle sends a beacon every ~200ms, so if it is not found in (scanTimeout=1) seconds, + // it is likely not in range and not worth retrying. + scanTimeout := config.AppConfig.ScanTimeout + scanCtx, cancelScan := context.WithCancel(ctx) + if scanTimeout > 0 { + scanCtx, cancelScan = context.WithTimeout(ctx, time.Duration(scanTimeout)*time.Second) + } + defer cancelScan() + + beacon, err := ble.ScanVehicleBeacon(scanCtx, firstCommand.Vin) if err != nil { if strings.Contains(err.Error(), "operation not permitted") { // The underlying BLE package calls HCIDEVDOWN on the BLE device, presumably as a // heavy-handed way of dealing with devices that are in a bad state. - return nil, nil, false, fmt.Errorf("failed to connect to vehicle (A): %s\nTry again after granting this application CAP_NET_ADMIN:\nsudo setcap 'cap_net_admin=eip' \"$(which %s)\"", err, os.Args[0]) + return nil, nil, false, fmt.Errorf("failed to scan for vehicle: %s\nTry again after granting this application CAP_NET_ADMIN:\nsudo setcap 'cap_net_admin=eip' \"$(which %s)\"", err, os.Args[0]) + } else if scanCtx.Err() != nil { + return nil, nil, false, fmt.Errorf("vehicle not in range: %s", err) } else { - return nil, nil, true, fmt.Errorf("failed to connect to vehicle (A): %s", err) + return nil, nil, true, fmt.Errorf("failed to scan for vehicle: %s", err) } } + + log.Debug("beacon found", "localName", beacon.LocalName(), "addr", beacon.Addr(), "rssi", beacon.RSSI()) + + log.Debug("dialing to vehicle ...") + conn, err = ble.NewConnectionToBleTarget(ctx, firstCommand.Vin, beacon) + if err != nil { + return nil, nil, true, fmt.Errorf("failed to connect to vehicle (A): %s", err) + } //defer conn.Close() log.Debug("create vehicle object ...") From 6ca3e0ee444d9e8d642097b2bf9c636d00a501df Mon Sep 17 00:00:00 2001 From: Lenart Kos Date: Sat, 18 Jan 2025 17:32:38 +0100 Subject: [PATCH 02/41] Add BLE connection status --- internal/api/handlers/tesla.go | 38 ++++++++++++++ internal/api/routes/routes.go | 1 + internal/ble/control/control.go | 90 ++++++++++++++++++++++++++++++++- 3 files changed, 128 insertions(+), 1 deletion(-) diff --git a/internal/api/handlers/tesla.go b/internal/api/handlers/tesla.go index 225f671..a2af0e7 100644 --- a/internal/api/handlers/tesla.go +++ b/internal/api/handlers/tesla.go @@ -215,6 +215,44 @@ func BodyControllerState(w http.ResponseWriter, r *http.Request) { } } +func BleConnectionStatus(w http.ResponseWriter, r *http.Request) { + ShowRequest(r, "BleConnectionStatus") + params := mux.Vars(r) + vin := params["vin"] + command := "connection_status" + + var response models.Response + response.Vin = vin + response.Command = command + + defer commonDefer(w, &response) + + if !checkBleControl(&response) { + return + } + + var apiResponse models.ApiResponse + wg := sync.WaitGroup{} + apiResponse.Wait = &wg + apiResponse.Ctx = r.Context() + + wg.Add(1) + control.BleControlInstance.PushCommand(command, vin, nil, &apiResponse) + + wg.Wait() + + SetCacheControl(w, config.AppConfig.CacheMaxAge) + + if apiResponse.Result { + response.Result = true + response.Reason = "The request was successfully processed." + response.Response = apiResponse.Response + } else { + response.Result = false + response.Reason = apiResponse.Error + } +} + func ShowRequest(r *http.Request, handler string) { log.Debug("received", "handler", handler, "method", r.Method, "url", r.URL, "from", r.RemoteAddr) } diff --git a/internal/api/routes/routes.go b/internal/api/routes/routes.go index 6db86b8..4f08fe1 100644 --- a/internal/api/routes/routes.go +++ b/internal/api/routes/routes.go @@ -16,6 +16,7 @@ func SetupRoutes(static embed.FS, html embed.FS) *mux.Router { router.HandleFunc("/api/1/vehicles/{vin}/command/{command}", handlers.Command).Methods("POST") router.HandleFunc("/api/1/vehicles/{vin}/vehicle_data", handlers.VehicleData).Methods("GET") router.HandleFunc("/api/1/vehicles/{vin}/body_controller_state", handlers.BodyControllerState).Methods("GET") + router.HandleFunc("/api/proxy/1/vehicles/{vin}/connection_status", handlers.BleConnectionStatus).Methods("GET") router.HandleFunc("/dashboard", handlers.ShowDashboard(html)).Methods("GET") router.HandleFunc("/gen_keys", handlers.GenKeys).Methods("GET") router.HandleFunc("/remove_keys", handlers.RemoveKeys).Methods("GET") diff --git a/internal/ble/control/control.go b/internal/ble/control/control.go index 8ceafde..c2fbfee 100644 --- a/internal/ble/control/control.go +++ b/internal/ble/control/control.go @@ -2,7 +2,9 @@ package control import ( "context" + "encoding/json" "fmt" + "math" "os" "strings" "time" @@ -34,7 +36,8 @@ func CloseBleControl() { } type BleControl struct { - privateKey protocol.ECDHPrivateKey + privateKey protocol.ECDHPrivateKey + operatedBeacon *ble.Advertisement commandStack chan commands.Command providerStack chan commands.Command @@ -89,7 +92,84 @@ func (bc *BleControl) PushCommand(command string, vin string, body map[string]in } } +func processIfConnectionStatusCommand(command *commands.Command, operated bool) bool { + if command.Command != "connection_status" { + return false + } + + defer func() { + if command.Response.Wait != nil { + command.Response.Wait.Done() + } + }() + + if BleControlInstance == nil { + command.Response.Error = "BleControl is not initialized. Maybe private.pem is missing." + command.Response.Result = false + return true + } else { + command.Response.Result = true + } + + var beacon ble.Advertisement = nil + + if operated { + if BleControlInstance.operatedBeacon != nil { + beacon = *BleControlInstance.operatedBeacon + } else { + log.Warn("operated beacon is nil but operated is true") + } + } else { + var err error + scanTimeout := config.AppConfig.ScanTimeout + scanCtx, cancelScan := context.WithCancel(command.Response.Ctx) + if scanTimeout > 0 { + scanCtx, cancelScan = context.WithTimeout(command.Response.Ctx, time.Duration(scanTimeout)*time.Second) + } + defer cancelScan() + beacon, err = ble.ScanVehicleBeacon(scanCtx, command.Vin) + if err != nil && !strings.Contains(err.Error(), "context deadline exceeded") { + command.Response.Error = err.Error() + command.Response.Result = false + return true + } + } + + var resp map[string]interface{} + if beacon != nil { + resp = map[string]interface{}{ + "local_name": beacon.LocalName(), + "connectable": beacon.Connectable(), + "address": beacon.Addr().String(), + "rssi": beacon.RSSI(), + "operated": operated, + } + } else { + resp = map[string]interface{}{ + "local_name": ble.VehicleLocalName(command.Vin), + "connectable": false, + "address": "", + "rssi": math.MinInt32, + "operated": false, + } + } + respBytes, err := json.Marshal(resp) + + if err != nil { + command.Response.Error = err.Error() + command.Response.Result = false + } else { + command.Response.Response = json.RawMessage(respBytes) + } + + return true +} + func (bc *BleControl) connectToVehicleAndOperateConnection(firstCommand *commands.Command) *commands.Command { + if processIfConnectionStatusCommand(firstCommand, false) { + return nil + } + log.Info("connecting to Vehicle ...") defer log.Debug("connecting to Vehicle done") @@ -251,6 +331,8 @@ func (bc *BleControl) TryConnectToVehicle(ctx context.Context, firstCommand *com log.Info("Key-Request connection established") } + bc.operatedBeacon = &beacon + // everything fine shouldDefer = false return conn, car, false, nil @@ -262,6 +344,8 @@ func (bc *BleControl) operateConnection(car *vehicle.Vehicle, firstCommand *comm connectionCtx, cancel := context.WithTimeout(context.Background(), 29*time.Second) defer cancel() + defer func() { bc.operatedBeacon = nil }() + if firstCommand.Command != "wake_up" { cmd, err, _ := bc.ExecuteCommand(car, firstCommand, connectionCtx) if err != nil { @@ -270,6 +354,10 @@ func (bc *BleControl) operateConnection(car *vehicle.Vehicle, firstCommand *comm } handleCommand := func(command *commands.Command) (doReturn bool, retryCommand *commands.Command) { + if processIfConnectionStatusCommand(command, command.Vin == firstCommand.Vin) { + return false, nil + } + //If new VIN, close connection if command.Vin != firstCommand.Vin { log.Debug("new VIN, so close connection") From 94d1fd8367f1ee085a04e94f99276fafecae2f08 Mon Sep 17 00:00:00 2001 From: Lenart Kos Date: Thu, 23 Jan 2025 23:21:05 +0100 Subject: [PATCH 03/41] Refactor command handling and implement most missing commands --- README.md | 67 +- internal/api/handlers/tesla.go | 230 ++--- internal/api/models/statesConverter.go | 235 +++-- internal/api/routes/routes.go | 7 +- internal/ble/control/control.go | 72 +- internal/tesla/commands/command.go | 58 +- internal/tesla/commands/commands.go | 301 +++--- internal/tesla/commands/commands_test.go | 28 - .../tesla/commands/fleetVehicleCommands.go | 898 ++++++++++++++++++ 9 files changed, 1374 insertions(+), 522 deletions(-) delete mode 100644 internal/tesla/commands/commands_test.go create mode 100644 internal/tesla/commands/fleetVehicleCommands.go diff --git a/README.md b/README.md index 0efd7ba..68d311e 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # TeslaBleHttpProxy -TeslaBleHttpProxy is a program written in Go that receives HTTP requests and forwards them via Bluetooth to a Tesla vehicle. The program can, for example, be easily used together with [evcc](/~https://github.com/evcc-io/evcc). +TeslaBleHttpProxy is a program written in Go that receives HTTP requests and forwards them via Bluetooth to a Tesla vehicle. The program can, for example, be easily used together with [evcc](/~https://github.com/evcc-io/evcc) or [TeslaBle2Mqtt](/~https://github.com/Lenart12/TeslaBle2Mqtt). The program stores the received requests in a queue and processes them one by one. This ensures that only one Bluetooth connection to the vehicle is established at a time. @@ -30,7 +30,7 @@ services: image: wimaha/tesla-ble-http-proxy container_name: tesla-ble-http-proxy environment: - - cacheMaxAge=30 # Optional, but recommended to set this to anything more than 0 if you are using the vehicle data + - cacheMaxAge=5 # Optional, but recommended to set this to anything more than 0 if you are using the vehicle data volumes: - ~/TeslaBleHttpProxy/key:/key - /var/run/dbus:/var/run/dbus @@ -120,24 +120,9 @@ If you want to use this proxy only for commands, and not for vehicle data, you c ### Vehicle Commands -The program uses the same interfaces as the Tesla [Fleet API](https://developer.tesla.com/docs/fleet-api#vehicle-commands). Currently, the following requests are supported: - -- wake_up -- charge_start -- charge_stop -- set_charging_amps -- set_charge_limit -- auto_conditioning_start -- auto_conditioning_stop -- charge_port_door_open -- charge_port_door_close -- flash_lights -- honk_horn -- door_lock -- door_unlock -- set_sentry_mode - -By default, the program will return immediately after sending the command to the vehicle. If you want to wait for the command to complete, you can set the `wait` parameter to `true`. +The program uses the same interfaces as the Tesla [Fleet API](https://developer.tesla.com/docs/fleet-api#vehicle-commands). Currently, most commands are supported. + +By default, the program will return immediately after sending the command to the vehicle. If you want to wait for the command to complete, you can set the `wait` parameter to `true` (`charge_start?wait=true`). #### Example Request @@ -180,17 +165,39 @@ This is recommended if you want to receive data frequently, since it will reduce ### Body Controller State The body controller state is fetched from the vehicle and returnes the state of the body controller. The request does not wake up the vehicle. The following information is returned: - -- `vehicleLockState` +- `closure_statuses` + - `charge_port` + - `CLOSURESTATE_CLOSED` + - `CLOSURESTATE_OPEN` + - `CLOSURESTATE_AJAR` + - `CLOSURESTATE_UNKNOWN` + - `CLOSURESTATE_FAILED_UNLATCH` + - `CLOSURESTATE_OPENING` + - `CLOSURESTATE_CLOSING` + - `front_driver_door` + - ... + - `front_passenger_door` + - ... + - `front_trunk` + - ... + - `rear_driver_door` + - ... + - `rear_passenger_door` + - ... + - `rear_trunk` + - ... + - `tonneau` + - ... +- `vehicle_lock_state` - `VEHICLELOCKSTATE_UNLOCKED` - `VEHICLELOCKSTATE_LOCKED` - `VEHICLELOCKSTATE_INTERNAL_LOCKED` - `VEHICLELOCKSTATE_SELECTIVE_UNLOCKED` -- `vehicleSleepStatus` +- `vehicle_sleep_status` - `VEHICLE_SLEEP_STATUS_UNKNOWN` - `VEHICLE_SLEEP_STATUS_AWAKE` - `VEHICLE_SLEEP_STATUS_ASLEEP` -- `userPresence` +- `user_presence` - `VEHICLE_USER_PRESENCE_UNKNOWN` - `VEHICLE_USER_PRESENCE_NOT_PRESENT` - `VEHICLE_USER_PRESENCE_PRESENT` @@ -200,4 +207,14 @@ The body controller state is fetched from the vehicle and returnes the state of *(All requests with method GET.)* Get body controller state: -`http://localhost:8080/api/1/vehicles/{VIN}/body_controller_state` +`http://localhost:8080/api/proxy/1/vehicles/{VIN}/body_controller_state` + +### Connection status + +Get BLE connection status of the vehicle +`GET http://localhost:8080/api/proxy/1/vehicles/LRWYGCFSXPC882647/connection_status` +- `address` +- `connectable` +- `local_name` +- `operated` +- `rssi` \ No newline at end of file diff --git a/internal/api/handlers/tesla.go b/internal/api/handlers/tesla.go index a2af0e7..d9cf040 100644 --- a/internal/api/handlers/tesla.go +++ b/internal/api/handlers/tesla.go @@ -1,14 +1,12 @@ package handlers import ( - "context" "encoding/json" "fmt" + "io" "net/http" - "slices" "strings" "sync" - "time" "github.com/charmbracelet/log" "github.com/gorilla/mux" @@ -18,7 +16,7 @@ import ( "github.com/wimaha/TeslaBleHttpProxy/internal/tesla/commands" ) -func commonDefer(w http.ResponseWriter, response *models.Response) { +func writeResponseWithStatus(w http.ResponseWriter, response *models.Response) { var ret models.Ret ret.Response = *response @@ -43,50 +41,36 @@ func checkBleControl(response *models.Response) bool { return true } -func Command(w http.ResponseWriter, r *http.Request) { - ShowRequest(r, "Command") - params := mux.Vars(r) - vin := params["vin"] - command := params["command"] - - wait := r.URL.Query().Get("wait") == "true" - +func processCommand(w http.ResponseWriter, r *http.Request, vin string, command_name string, src commands.CommandSourceType, body map[string]interface{}, wait bool) models.Response { var response models.Response response.Vin = vin - response.Command = command - - defer commonDefer(w, &response) + response.Command = command_name if !checkBleControl(&response) { - return - } - - //Body - var body map[string]interface{} = nil - if err := json.NewDecoder(r.Body).Decode(&body); err != nil && err.Error() != "EOF" && !strings.Contains(err.Error(), "cannot unmarshal bool") { - log.Error("decoding body", "err", err) + return response } - log.Info("received", "command", command, "body", body) - - if !slices.Contains(commands.ExceptedCommands, command) { - log.Error("not supported", "command", command) - response.Reason = fmt.Sprintf("The command \"%s\" is not supported.", command) - response.Result = false - return + var apiResponse models.ApiResponse + command := commands.Command{ + Command: command_name, + Source: src, + Vin: vin, + Body: body, } if wait { - var apiResponse models.ApiResponse wg := sync.WaitGroup{} + command.Response = &apiResponse apiResponse.Wait = &wg apiResponse.Ctx = r.Context() wg.Add(1) - control.BleControlInstance.PushCommand(command, vin, body, &apiResponse) + control.BleControlInstance.PushCommand(command) wg.Wait() + SetCacheControl(w, config.AppConfig.CacheMaxAge) + if apiResponse.Result { response.Result = true response.Reason = "The command was successfully processed." @@ -95,162 +79,106 @@ func Command(w http.ResponseWriter, r *http.Request) { response.Result = false response.Reason = apiResponse.Error } - return + } else { + control.BleControlInstance.PushCommand(command) + response.Result = true + response.Reason = "The command was successfully received and will be processed shortly." } - control.BleControlInstance.PushCommand(command, vin, body, nil) - response.Result = true - response.Reason = "The command was successfully received and will be processed shortly." + return response } -func VehicleData(w http.ResponseWriter, r *http.Request) { - ShowRequest(r, "VehicleData") +func VehicleCommand(w http.ResponseWriter, r *http.Request) { + ShowRequest(r, "Command") params := mux.Vars(r) vin := params["vin"] - command := "vehicle_data" + command := params["command"] - var endpoints []string - endpointsString := r.URL.Query().Get("endpoints") - if endpointsString != "" { - endpoints = strings.Split(endpointsString, ";") - } else { - endpoints = []string{"charge_state", "climate_state"} //'charge_state', 'climate_state', 'closures_state', 'drive_state', 'gui_settings', 'location_data', 'charge_schedule_data', 'preconditioning_schedule_data', 'vehicle_config', 'vehicle_state', 'vehicle_data_combo' - } + wait := r.URL.Query().Get("wait") == "true" - var response models.Response - response.Vin = vin - response.Command = command + var body map[string]interface{} = nil - for _, endpoint := range endpoints { - if !slices.Contains(commands.ExceptedEndpoints, endpoint) { - log.Error("not supported", "endpoint", endpoint) - response.Reason = fmt.Sprintf("The endpoint \"%s\" is not supported.", endpoint) - response.Result = false + // Check if the body is empty + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + if err != io.EOF { + log.Error("decoding body", "err", err) + writeResponseWithStatus(w, &models.Response{Vin: vin, Command: command, Result: false, Reason: "Failed to decode body"}) return } } - defer commonDefer(w, &response) - - if !checkBleControl(&response) { + if err := commands.ValidateFleetVehicleCommand(command, body); err != nil { + writeResponseWithStatus(w, &models.Response{Vin: vin, Command: command, Result: false, Reason: err.Error()}) return } - var apiResponse models.ApiResponse - wg := sync.WaitGroup{} - apiResponse.Wait = &wg - apiResponse.Ctx = r.Context() - - wg.Add(1) - control.BleControlInstance.PushCommand(command, vin, map[string]interface{}{"endpoints": endpoints}, &apiResponse) - - wg.Wait() - - SetCacheControl(w, config.AppConfig.CacheMaxAge) + log.Info("received", "command", command, "body", body) - if apiResponse.Result { - response.Result = true - response.Reason = "The request was successfully processed." - response.Response = apiResponse.Response - } else { - response.Result = false - response.Reason = apiResponse.Error - } + resp := processCommand(w, r, vin, command, commands.CommandSource.FleetVehicleCommands, body, wait) + writeResponseWithStatus(w, &resp) } -func BodyControllerState(w http.ResponseWriter, r *http.Request) { - ShowRequest(r, "BodyControllerState") +func VehicleEndpoint(w http.ResponseWriter, r *http.Request) { + ShowRequest(r, "VehicleEndpoint") params := mux.Vars(r) vin := params["vin"] + command := params["command"] - var response models.Response - response.Vin = vin - response.Command = "body-controller-state" - - defer commonDefer(w, &response) - - if !checkBleControl(&response) { - return - } + var body map[string]interface{} = nil - var apiResponse models.ApiResponse + src := commands.CommandSource.FleetVehicleEndpoint - ctx, cancel := context.WithTimeout(r.Context(), 15*time.Second) - apiResponse.Ctx = ctx - defer cancel() - cmd := &commands.Command{ - Command: "body-controller-state", - Domain: commands.Domain.VCSEC, - Vin: vin, - Response: &apiResponse, - } - conn, car, _, err := control.BleControlInstance.TryConnectToVehicle(ctx, cmd) - if err == nil { - //Successful - defer conn.Close() - defer log.Debug("close connection (A)") - defer car.Disconnect() - defer log.Debug("disconnect vehicle (A)") - - _, err, _ := control.BleControlInstance.ExecuteCommand(car, cmd, context.Background()) - if err != nil { - response.Result = false - response.Reason = err.Error() - return + switch command { + case "wake_up": + case "vehicle_data": + var endpoints []string + endpointsString := r.URL.Query().Get("endpoints") + if endpointsString != "" { + endpoints = strings.Split(endpointsString, ";") + } else { + // 'charge_state', 'climate_state', 'closures_state', + // 'drive_state', 'gui_settings', 'location_data', + // 'charge_schedule_data', 'preconditioning_schedule_data', + // 'vehicle_config', 'vehicle_state', 'vehicle_data_combo' + endpoints = []string{"charge_state", "climate_state"} } - SetCacheControl(w, config.AppConfig.CacheMaxAge) - - if apiResponse.Result { - response.Result = true - response.Reason = "The request was successfully processed." - response.Response = apiResponse.Response - } else { - response.Result = false - response.Reason = apiResponse.Error + // Ensure that the endpoints are valid + for _, endpoint := range endpoints { + if _, err := commands.GetCategory(endpoint); err != nil { + writeResponseWithStatus(w, &models.Response{Vin: vin, Command: command, Result: false, Reason: err.Error()}) + return + } } - } else { - response.Result = false - response.Reason = err.Error() + + body = map[string]interface{}{"endpoints": endpoints} + default: + writeResponseWithStatus(w, &models.Response{Vin: vin, Command: command, Result: false, Reason: "Unrecognized command: " + command}) + return } + + log.Info("received", "command", command, "body", body) + resp := processCommand(w, r, vin, command, src, body, true) + writeResponseWithStatus(w, &resp) } -func BleConnectionStatus(w http.ResponseWriter, r *http.Request) { - ShowRequest(r, "BleConnectionStatus") +func ProxyCommand(w http.ResponseWriter, r *http.Request) { + ShowRequest(r, "ProxyCommand") params := mux.Vars(r) vin := params["vin"] - command := "connection_status" - - var response models.Response - response.Vin = vin - response.Command = command - - defer commonDefer(w, &response) + command := params["command"] - if !checkBleControl(&response) { + switch command { + case "connection_status": + case "body_controller_state": + default: + writeResponseWithStatus(w, &models.Response{Vin: vin, Command: command, Result: false, Reason: "Unrecognized command: " + command}) return } - var apiResponse models.ApiResponse - wg := sync.WaitGroup{} - apiResponse.Wait = &wg - apiResponse.Ctx = r.Context() - - wg.Add(1) - control.BleControlInstance.PushCommand(command, vin, nil, &apiResponse) - - wg.Wait() - - SetCacheControl(w, config.AppConfig.CacheMaxAge) - - if apiResponse.Result { - response.Result = true - response.Reason = "The request was successfully processed." - response.Response = apiResponse.Response - } else { - response.Result = false - response.Reason = apiResponse.Error - } + log.Info("received", "command", command) + resp := processCommand(w, r, vin, command, commands.CommandSource.TeslaBleHttpProxy, nil, true) + writeResponseWithStatus(w, &resp) } func ShowRequest(r *http.Request, handler string) { diff --git a/internal/api/models/statesConverter.go b/internal/api/models/statesConverter.go index 5dca001..098b8ef 100644 --- a/internal/api/models/statesConverter.go +++ b/internal/api/models/statesConverter.go @@ -4,63 +4,94 @@ import ( "strings" "github.com/teslamotors/vehicle-command/pkg/protocol/protobuf/carserver" + "github.com/teslamotors/vehicle-command/pkg/protocol/protobuf/vcsec" ) -func flatten(s string) string { +func flatten(s string) any { + if s == "" { + return nil + } return strings.ReplaceAll(s, ":{}", "") } -func ChargeStateFromBle(VehicleData *carserver.VehicleData) ChargeState { - return ChargeState{ - Timestamp: VehicleData.ChargeState.GetTimestamp().AsTime().Unix(), - ChargingState: flatten(VehicleData.ChargeState.GetChargingState().String()), - ChargeLimitSoc: VehicleData.ChargeState.GetChargeLimitSoc(), - ChargeLimitSocStd: VehicleData.ChargeState.GetChargeLimitSocStd(), - ChargeLimitSocMin: VehicleData.ChargeState.GetChargeLimitSocMin(), - ChargeLimitSocMax: VehicleData.ChargeState.GetChargeLimitSocMax(), - MaxRangeChargeCounter: VehicleData.ChargeState.GetMaxRangeChargeCounter(), - FastChargerPresent: VehicleData.ChargeState.GetFastChargerPresent(), - FastChargerType: flatten(VehicleData.ChargeState.GetFastChargerType().String()), - BatteryRange: VehicleData.ChargeState.GetBatteryRange(), - EstBatteryRange: VehicleData.ChargeState.GetEstBatteryRange(), - IdealBatteryRange: VehicleData.ChargeState.GetIdealBatteryRange(), - BatteryLevel: VehicleData.ChargeState.GetBatteryLevel(), - UsableBatteryLevel: VehicleData.ChargeState.GetUsableBatteryLevel(), - ChargeEnergyAdded: VehicleData.ChargeState.GetChargeEnergyAdded(), - ChargeMilesAddedRated: VehicleData.ChargeState.GetChargeMilesAddedRated(), - ChargeMilesAddedIdeal: VehicleData.ChargeState.GetChargeMilesAddedIdeal(), - ChargerVoltage: VehicleData.ChargeState.GetChargerVoltage(), - ChargerPilotCurrent: VehicleData.ChargeState.GetChargerPilotCurrent(), - ChargerActualCurrent: VehicleData.ChargeState.GetChargerActualCurrent(), - ChargerPower: VehicleData.ChargeState.GetChargerPower(), - TripCharging: VehicleData.ChargeState.GetTripCharging(), - ChargeRate: VehicleData.ChargeState.GetChargeRateMphFloat(), - ChargePortDoorOpen: VehicleData.ChargeState.GetChargePortDoorOpen(), - ScheduledChargingMode: flatten(VehicleData.ChargeState.GetScheduledChargingMode().String()), - ScheduledDepatureTime: VehicleData.ChargeState.GetScheduledDepartureTime().AsTime().Unix(), - ScheduledDepatureTimeMinutes: VehicleData.ChargeState.GetScheduledDepartureTimeMinutes(), - SuperchargerSessionTripPlanner: VehicleData.ChargeState.GetSuperchargerSessionTripPlanner(), - ScheduledChargingStartTime: VehicleData.ChargeState.GetScheduledChargingStartTime(), - ScheduledChargingPending: VehicleData.ChargeState.GetScheduledChargingPending(), - UserChargeEnableRequest: VehicleData.ChargeState.GetUserChargeEnableRequest(), - ChargeEnableRequest: VehicleData.ChargeState.GetChargeEnableRequest(), - ChargerPhases: VehicleData.ChargeState.GetChargerPhases(), - ChargePortLatch: flatten(VehicleData.ChargeState.GetChargePortLatch().String()), - ChargeCurrentRequest: VehicleData.ChargeState.GetChargeCurrentRequest(), - ChargeCurrentRequestMax: VehicleData.ChargeState.GetChargeCurrentRequestMax(), - ChargeAmps: VehicleData.ChargeState.GetChargingAmps(), - OffPeakChargingTimes: flatten(VehicleData.ChargeState.GetOffPeakChargingTimes().String()), - OffPeakHoursEndTime: VehicleData.ChargeState.GetOffPeakHoursEndTime(), - PreconditioningEnabled: VehicleData.ChargeState.GetPreconditioningEnabled(), - PreconditioningTimes: flatten(VehicleData.ChargeState.GetPreconditioningTimes().String()), - ManagedChargingActive: VehicleData.ChargeState.GetManagedChargingActive(), - ManagedChargingUserCanceled: VehicleData.ChargeState.GetManagedChargingUserCanceled(), - ManagedChargingStartTime: VehicleData.ChargeState.GetManagedChargingStartTime(), - ChargePortcoldWeatherMode: VehicleData.ChargeState.GetChargePortColdWeatherMode(), - ChargePortColor: flatten(VehicleData.ChargeState.GetChargePortColor().String()), - ConnChargeCable: flatten(VehicleData.ChargeState.GetConnChargeCable().String()), - FastChargerBrand: flatten(VehicleData.ChargeState.GetFastChargerBrand().String()), - MinutesToFullCharge: VehicleData.ChargeState.GetMinutesToFullCharge(), +func BodyControllerStateFromBle(vehicleData *vcsec.VehicleStatus) map[string]interface{} { + cs := &vehicleData.ClosureStatuses + dcs := &vehicleData.DetailedClosureStatus + return map[string]interface{}{ + "closure_statuses": map[string]interface{}{ + "front_driver_door": flatten((*cs).GetFrontDriverDoor().String()), + "front_passenger_door": flatten((*cs).GetFrontPassengerDoor().String()), + "rear_driver_door": flatten((*cs).GetRearDriverDoor().String()), + "rear_passenger_door": flatten((*cs).GetRearPassengerDoor().String()), + "rear_trunk": flatten((*cs).GetRearTrunk().String()), + "front_trunk": flatten((*cs).GetFrontTrunk().String()), + "charge_port": flatten((*cs).GetChargePort().String()), + "tonneau": flatten((*cs).GetTonneau().String()), + }, + "detailed_closure_status": map[string]interface{}{ + "tonneau_percent_open": (*dcs).GetTonneauPercentOpen(), + }, + "user_presence": flatten(vehicleData.GetUserPresence().String()), + "vehicle_lock_state": flatten(vehicleData.GetVehicleLockState().String()), + "vehicle_sleep_status": flatten(vehicleData.GetVehicleSleepStatus().String()), + } +} + +func ChargeStateFromBle(vehicleData *carserver.VehicleData) map[string]interface{} { + cs := vehicleData.ChargeState + return map[string]interface{}{ + "timestamp": (*cs).GetTimestamp().AsTime().Unix(), + "charging_state": flatten((*cs).GetChargingState().String()), + "charge_limit_soc": (*cs).GetChargeLimitSoc(), + "charge_limit_soc_std": (*cs).GetChargeLimitSocStd(), + "charge_limit_soc_min": (*cs).GetChargeLimitSocMin(), + "charge_limit_soc_max": (*cs).GetChargeLimitSocMax(), + "max_range_charge_counter": (*cs).GetMaxRangeChargeCounter(), + "fast_charger_present": (*cs).GetFastChargerPresent(), + "fast_charger_type": flatten((*cs).GetFastChargerType().String()), + "battery_range": (*cs).GetBatteryRange(), + "est_battery_range": (*cs).GetEstBatteryRange(), + "ideal_battery_range": (*cs).GetIdealBatteryRange(), + "battery_level": (*cs).GetBatteryLevel(), + "usable_battery_level": (*cs).GetUsableBatteryLevel(), + "charge_energy_added": (*cs).GetChargeEnergyAdded(), + "charge_miles_added_rated": (*cs).GetChargeMilesAddedRated(), + "charge_miles_added_ideal": (*cs).GetChargeMilesAddedIdeal(), + "charger_voltage": (*cs).GetChargerVoltage(), + "charger_pilot_current": (*cs).GetChargerPilotCurrent(), + "charger_actual_current": (*cs).GetChargerActualCurrent(), + "charger_power": (*cs).GetChargerPower(), + "trip_charging": (*cs).GetTripCharging(), + "charge_rate": (*cs).GetChargeRateMphFloat(), + "charge_port_door_open": (*cs).GetChargePortDoorOpen(), + "scheduled_charging_mode": flatten((*cs).GetScheduledChargingMode().String()), + "scheduled_departure_time": (*cs).GetScheduledDepartureTime().AsTime().Unix(), + "scheduled_departure_time_minutes": (*cs).GetScheduledDepartureTimeMinutes(), + "supercharger_session_trip_planner": (*cs).GetSuperchargerSessionTripPlanner(), + "scheduled_charging_start_time": (*cs).GetScheduledChargingStartTime(), + "scheduled_charging_pending": (*cs).GetScheduledChargingPending(), + "user_charge_enable_request": (*cs).GetUserChargeEnableRequest(), + "charge_enable_request": (*cs).GetChargeEnableRequest(), + "charger_phases": (*cs).GetChargerPhases(), + "charge_port_latch": flatten((*cs).GetChargePortLatch().String()), + "charge_current_request": (*cs).GetChargeCurrentRequest(), + "charge_current_request_max": (*cs).GetChargeCurrentRequestMax(), + "charge_amps": (*cs).GetChargingAmps(), + "off_peak_charging_times": flatten((*cs).GetOffPeakChargingTimes().String()), + "off_peak_hours_end_time": (*cs).GetOffPeakHoursEndTime(), + "preconditioning_enabled": (*cs).GetPreconditioningEnabled(), + "preconditioning_times": flatten((*cs).GetPreconditioningTimes().String()), + "managed_charging_active": (*cs).GetManagedChargingActive(), + "managed_charging_user_canceled": (*cs).GetManagedChargingUserCanceled(), + "managed_charging_start_time": (*cs).GetManagedChargingStartTime(), + "charge_port_cold_weather_mode": (*cs).GetChargePortColdWeatherMode(), + "charge_port_color": flatten((*cs).GetChargePortColor().String()), + "conn_charge_cable": flatten((*cs).GetConnChargeCable().String()), + "fast_charger_brand": flatten((*cs).GetFastChargerBrand().String()), + "minutes_to_full_charge": (*cs).GetMinutesToFullCharge(), + // "battery_heater_on": (*cs).GetBatteryHeaterOn(), + // "not_enough_power_to_heat": (*cs).GetNotEnoughPowerToHeat(), + // "off_peak_charging_enabled": (*cs).GetOffPeakChargingEnabled(), } } @@ -72,49 +103,50 @@ MISSING OffPeakChargingEnabled bool `json:"off_peak_charging_enabled"` */ -func ClimateStateFromBle(VehicleData *carserver.VehicleData) ClimateState { - return ClimateState{ - Timestamp: VehicleData.ClimateState.GetTimestamp().AsTime().Unix(), - AllowCabinOverheatProtection: VehicleData.ClimateState.GetAllowCabinOverheatProtection(), - AutoSeatClimateLeft: VehicleData.ClimateState.GetAutoSeatClimateLeft(), - AutoSeatClimateRight: VehicleData.ClimateState.GetAutoSeatClimateRight(), - AutoSteeringWheelHeat: VehicleData.ClimateState.GetAutoSteeringWheelHeat(), - BioweaponMode: VehicleData.ClimateState.GetBioweaponModeOn(), - CabinOverheatProtection: flatten(VehicleData.ClimateState.GetCabinOverheatProtection().String()), - CabinOverheatProtectionActivelyCooling: VehicleData.ClimateState.GetCabinOverheatProtectionActivelyCooling(), - CopActivationTemperature: flatten(VehicleData.ClimateState.GetCopActivationTemperature().String()), - InsideTemp: VehicleData.ClimateState.GetInsideTempCelsius(), - OutsideTemp: VehicleData.ClimateState.GetOutsideTempCelsius(), - DriverTempSetting: VehicleData.ClimateState.GetDriverTempSetting(), - PassengerTempSetting: VehicleData.ClimateState.GetPassengerTempSetting(), - LeftTempDirection: VehicleData.ClimateState.GetLeftTempDirection(), - RightTempDirection: VehicleData.ClimateState.GetRightTempDirection(), - IsAutoConditioningOn: VehicleData.ClimateState.GetIsAutoConditioningOn(), - IsFrontDefrosterOn: VehicleData.ClimateState.GetIsFrontDefrosterOn(), - IsRearDefrosterOn: VehicleData.ClimateState.GetIsRearDefrosterOn(), - FanStatus: VehicleData.ClimateState.GetFanStatus(), - HvacAutoRequest: flatten(VehicleData.ClimateState.GetHvacAutoRequest().String()), - IsClimateOn: VehicleData.ClimateState.GetIsClimateOn(), - MinAvailTemp: VehicleData.ClimateState.GetMinAvailTempCelsius(), - MaxAvailTemp: VehicleData.ClimateState.GetMaxAvailTempCelsius(), - SeatHeaterLeft: VehicleData.ClimateState.GetSeatHeaterLeft(), - SeatHeaterRight: VehicleData.ClimateState.GetSeatHeaterRight(), - SeatHeaterRearLeft: VehicleData.ClimateState.GetSeatHeaterRearLeft(), - SeatHeaterRearRight: VehicleData.ClimateState.GetSeatHeaterRearRight(), - SeatHeaterRearCenter: VehicleData.ClimateState.GetSeatHeaterRearCenter(), - SeatHeaterRearRightBack: VehicleData.ClimateState.GetSeatHeaterRearRightBack(), - SeatHeaterRearLeftBack: VehicleData.ClimateState.GetSeatHeaterRearLeftBack(), - SteeringWheelHeatLevel: int32(*VehicleData.ClimateState.GetSteeringWheelHeatLevel().Enum()), - SteeringWheelHeater: VehicleData.ClimateState.GetSteeringWheelHeater(), - SupportsFanOnlyCabinOverheatProtection: VehicleData.ClimateState.GetSupportsFanOnlyCabinOverheatProtection(), - BatteryHeater: VehicleData.ClimateState.GetBatteryHeater(), - BatteryHeaterNoPower: VehicleData.ClimateState.GetBatteryHeaterNoPower(), - ClimateKeeperMode: flatten(VehicleData.ClimateState.GetClimateKeeperMode().String()), - DefrostMode: flatten(VehicleData.ClimateState.GetDefrostMode().String()), - IsPreconditioning: VehicleData.ClimateState.GetIsPreconditioning(), - RemoteHeaterControlEnabled: VehicleData.ClimateState.GetRemoteHeaterControlEnabled(), - SideMirrorHeaters: VehicleData.ClimateState.GetSideMirrorHeaters(), - WiperBladeHeater: VehicleData.ClimateState.GetWiperBladeHeater(), +func ClimateStateFromBle(vehicleData *carserver.VehicleData) map[string]interface{} { + cs := &vehicleData.ClimateState + return map[string]interface{}{ + "timestamp": (*cs).GetTimestamp().AsTime().Unix(), + "allow_cabin_overheat_protection": (*cs).GetAllowCabinOverheatProtection(), + "auto_seat_climate_left": (*cs).GetAutoSeatClimateLeft(), + "auto_seat_climate_right": (*cs).GetAutoSeatClimateRight(), + "auto_steering_wheel_heat": (*cs).GetAutoSteeringWheelHeat(), + "bioweapon_mode": (*cs).GetBioweaponModeOn(), + "cabin_overheat_protection": flatten((*cs).GetCabinOverheatProtection().String()), + "cabin_overheat_protection_actively_cooling": (*cs).GetCabinOverheatProtectionActivelyCooling(), + "cop_activation_temperature": flatten((*cs).GetCopActivationTemperature().String()), + "inside_temp": (*cs).GetInsideTempCelsius(), + "outside_temp": (*cs).GetOutsideTempCelsius(), + "driver_temp_setting": (*cs).GetDriverTempSetting(), + "passenger_temp_setting": (*cs).GetPassengerTempSetting(), + "left_temp_direction": (*cs).GetLeftTempDirection(), + "right_temp_direction": (*cs).GetRightTempDirection(), + "is_auto_conditioning_on": (*cs).GetIsAutoConditioningOn(), + "is_front_defroster_on": (*cs).GetIsFrontDefrosterOn(), + "is_rear_defroster_on": (*cs).GetIsRearDefrosterOn(), + "fan_status": (*cs).GetFanStatus(), + "hvac_auto_request": flatten((*cs).GetHvacAutoRequest().String()), + "is_climate_on": (*cs).GetIsClimateOn(), + "min_avail_temp": (*cs).GetMinAvailTempCelsius(), + "max_avail_temp": (*cs).GetMaxAvailTempCelsius(), + "seat_heater_left": (*cs).GetSeatHeaterLeft(), + "seat_heater_right": (*cs).GetSeatHeaterRight(), + "seat_heater_rear_left": (*cs).GetSeatHeaterRearLeft(), + "seat_heater_rear_right": (*cs).GetSeatHeaterRearRight(), + "seat_heater_rear_center": (*cs).GetSeatHeaterRearCenter(), + "seat_heater_rear_right_back": (*cs).GetSeatHeaterRearRightBack(), + "seat_heater_rear_left_back": (*cs).GetSeatHeaterRearLeftBack(), + "steering_wheel_heat_level": int32(*(*cs).GetSteeringWheelHeatLevel().Enum()), + "steering_wheel_heater": (*cs).GetSteeringWheelHeater(), + "supports_fan_only_cabin_overheat_protection": (*cs).GetSupportsFanOnlyCabinOverheatProtection(), + "battery_heater": (*cs).GetBatteryHeater(), + "battery_heater_no_power": (*cs).GetBatteryHeaterNoPower(), + "climate_keeper_mode": flatten((*cs).GetClimateKeeperMode().String()), + "defrost_mode": flatten((*cs).GetDefrostMode().String()), + "is_preconditioning": (*cs).GetIsPreconditioning(), + "remote_heater_control_enabled": (*cs).GetRemoteHeaterControlEnabled(), + "side_mirror_heaters": (*cs).GetSideMirrorHeaters(), + "wiper_blade_heater": (*cs).GetWiperBladeHeater(), } } @@ -122,3 +154,16 @@ func ClimateStateFromBle(VehicleData *carserver.VehicleData) ClimateState { MISSING SmartPreconditioning bool `json:"smart_preconditioning"` */ + +// func ChargeStateFromBle(vehicleData *carserver.VehicleData) map[string]interface{} {} +// func ClimateStateFromBle(vehicleData *carserver.VehicleData) map[string]interface{} {) +// func DriveStateFromBle(vehicleData *carserver.VehicleData) map[string]interface{} { +// func LocationDataFromBle(vehicleData *carserver.VehicleData) map[string]interface{} {) +// func ClosuresStateFromBle(vehicleData *carserver.VehicleData) map[string]interface{} {) +// func ChargeScheduleDataFromBle(vehicleData *carserver.VehicleData) map[string]interface{} {) +// func PreconditioningScheduleDataFromBle(vehicleData *carserver.VehicleData) map[string]interface{} {) +// func TirePressureFromBle(vehicleData *carserver.VehicleData) map[string]interface{} {) +// func MediaFromBle(vehicleData *carserver.VehicleData) map[string]interface{} { +// func MediaDetailFromBle(vehicleData *carserver.VehicleData) map[string]interface{} { +// func SoftwareUpdateFromBle(vehicleData *carserver.VehicleData) map[string]interface{} {) +// func ParentalControlsFromBle(vehicleData *carserver.VehicleData) map[string]interface{} {) diff --git a/internal/api/routes/routes.go b/internal/api/routes/routes.go index 4f08fe1..dcb173c 100644 --- a/internal/api/routes/routes.go +++ b/internal/api/routes/routes.go @@ -13,10 +13,9 @@ func SetupRoutes(static embed.FS, html embed.FS) *mux.Router { // Define the endpoints ///api/1/vehicles/{vehicle_tag}/command/set_charging_amps - router.HandleFunc("/api/1/vehicles/{vin}/command/{command}", handlers.Command).Methods("POST") - router.HandleFunc("/api/1/vehicles/{vin}/vehicle_data", handlers.VehicleData).Methods("GET") - router.HandleFunc("/api/1/vehicles/{vin}/body_controller_state", handlers.BodyControllerState).Methods("GET") - router.HandleFunc("/api/proxy/1/vehicles/{vin}/connection_status", handlers.BleConnectionStatus).Methods("GET") + router.HandleFunc("/api/1/vehicles/{vin}/command/{command}", handlers.VehicleCommand).Methods("POST") + router.HandleFunc("/api/1/vehicles/{vin}/{command}", handlers.VehicleEndpoint).Methods("GET") + router.HandleFunc("/api/proxy/1/vehicles/{vin}/{command}", handlers.ProxyCommand).Methods("GET") router.HandleFunc("/dashboard", handlers.ShowDashboard(html)).Methods("GET") router.HandleFunc("/gen_keys", handlers.GenKeys).Methods("GET") router.HandleFunc("/remove_keys", handlers.RemoveKeys).Methods("GET") diff --git a/internal/ble/control/control.go b/internal/ble/control/control.go index c2fbfee..d0a80d6 100644 --- a/internal/ble/control/control.go +++ b/internal/ble/control/control.go @@ -15,7 +15,6 @@ import ( "github.com/teslamotors/vehicle-command/pkg/protocol/protobuf/universalmessage" "github.com/teslamotors/vehicle-command/pkg/vehicle" "github.com/wimaha/TeslaBleHttpProxy/config" - "github.com/wimaha/TeslaBleHttpProxy/internal/api/models" "github.com/wimaha/TeslaBleHttpProxy/internal/tesla/commands" ) @@ -36,8 +35,9 @@ func CloseBleControl() { } type BleControl struct { - privateKey protocol.ECDHPrivateKey - operatedBeacon *ble.Advertisement + privateKey protocol.ECDHPrivateKey + operatedBeacon *ble.Advertisement + infotainmentSession bool commandStack chan commands.Command providerStack chan commands.Command @@ -83,13 +83,8 @@ func (bc *BleControl) Loop() { } } -func (bc *BleControl) PushCommand(command string, vin string, body map[string]interface{}, response *models.ApiResponse) { - bc.commandStack <- commands.Command{ - Command: command, - Vin: vin, - Body: body, - Response: response, - } +func (bc *BleControl) PushCommand(command commands.Command) { + bc.commandStack <- command } func processIfConnectionStatusCommand(command *commands.Command, operated bool) bool { @@ -236,6 +231,20 @@ func (bc *BleControl) connectToVehicleAndOperateConnection(firstCommand *command return commandError(lastErr) } +func (bc *BleControl) startInfotainmentSession(ctx context.Context, car *vehicle.Vehicle) error { + log.Debug("start Infotainment session...") + // Then we can also connect the infotainment + if err := car.StartSession(ctx, []universalmessage.Domain{ + protocol.DomainVCSEC, + protocol.DomainInfotainment, + }); err != nil { + return fmt.Errorf("failed to perform handshake with vehicle (B): %s", err) + } + log.Info("connection established") + bc.infotainmentSession = true + return nil +} + func (bc *BleControl) TryConnectToVehicle(ctx context.Context, firstCommand *commands.Command) (*ble.Connection, *vehicle.Vehicle, bool, error) { log.Debug("connecting to vehicle (A)...") var conn *ble.Connection @@ -286,6 +295,7 @@ func (bc *BleControl) TryConnectToVehicle(ctx context.Context, firstCommand *com if err != nil { return nil, nil, true, fmt.Errorf("failed to connect to vehicle (A): %s", err) } + bc.infotainmentSession = false //defer conn.Close() log.Debug("create vehicle object ...") @@ -310,22 +320,16 @@ func (bc *BleControl) TryConnectToVehicle(ctx context.Context, firstCommand *com return nil, nil, true, fmt.Errorf("failed to perform handshake with vehicle (A): %s", err) } - if firstCommand.Domain != commands.Domain.VCSEC { + if firstCommand.Domain() != commands.Domain.VCSEC { if err := car.Wakeup(ctx); err != nil { return nil, nil, true, fmt.Errorf("failed to wake up car: %s", err) } else { log.Debug("car successfully wakeup") } - log.Debug("start Infotainment session...") - // Then we can also connect the infotainment - if err := car.StartSession(ctx, []universalmessage.Domain{ - protocol.DomainVCSEC, - protocol.DomainInfotainment, - }); err != nil { - return nil, nil, true, fmt.Errorf("failed to perform handshake with vehicle (B): %s", err) + if err := bc.startInfotainmentSession(ctx, car); err != nil { + return nil, nil, true, err } - log.Info("connection established") } } else { log.Info("Key-Request connection established") @@ -341,18 +345,11 @@ func (bc *BleControl) TryConnectToVehicle(ctx context.Context, firstCommand *com func (bc *BleControl) operateConnection(car *vehicle.Vehicle, firstCommand *commands.Command) *commands.Command { log.Debug("operating connection ...") defer log.Debug("operating connection done") - connectionCtx, cancel := context.WithTimeout(context.Background(), 29*time.Second) + connectionCtx, cancel := context.WithTimeout(context.Background(), 290*time.Second) defer cancel() defer func() { bc.operatedBeacon = nil }() - if firstCommand.Command != "wake_up" { - cmd, err, _ := bc.ExecuteCommand(car, firstCommand, connectionCtx) - if err != nil { - return cmd - } - } - handleCommand := func(command *commands.Command) (doReturn bool, retryCommand *commands.Command) { if processIfConnectionStatusCommand(command, command.Vin == firstCommand.Vin) { return false, nil @@ -379,6 +376,11 @@ func (bc *BleControl) operateConnection(car *vehicle.Vehicle, firstCommand *comm return false, nil } + doReturn, retryCommand := handleCommand(firstCommand) + if doReturn { + return retryCommand + } + for { select { case <-connectionCtx.Done(): @@ -407,7 +409,7 @@ func (bc *BleControl) operateConnection(car *vehicle.Vehicle, firstCommand *comm } func (bc *BleControl) ExecuteCommand(car *vehicle.Vehicle, command *commands.Command, connectionCtx context.Context) (retryCommand *commands.Command, retErr error, ctx context.Context) { - log.Info("sending", "command", command.Command, "body", command.Body) + log.Debug("sending", "command", command.Command, "body", command.Body) if command.Response != nil && command.Response.Ctx != nil { ctx = command.Response.Ctx } else { @@ -465,6 +467,20 @@ func (bc *BleControl) ExecuteCommand(car *vehicle.Vehicle, command *commands.Com sleep *= 2 } + if !bc.infotainmentSession && command.Domain() == commands.Domain.Infotainment { + if err := car.Wakeup(ctx); err != nil { + lastErr = fmt.Errorf("failed to wake up car: %s", err) + continue + } else { + log.Debug("car successfully wakeup") + } + + if err := bc.startInfotainmentSession(ctx, car); err != nil { + lastErr = err + continue + } + } + retry, err := command.Send(ctx, car) if err == nil { //Successful diff --git a/internal/tesla/commands/command.go b/internal/tesla/commands/command.go index d0761a3..4501129 100644 --- a/internal/tesla/commands/command.go +++ b/internal/tesla/commands/command.go @@ -20,27 +20,59 @@ var Domain = struct { Infotainment: "infotainment", } +type CommandSourceType string + +var CommandSource = struct { + FleetVehicleCommands CommandSourceType + FleetVehicleEndpoint CommandSourceType + TeslaBleHttpProxy CommandSourceType +}{ + FleetVehicleCommands: "cmd", + FleetVehicleEndpoint: "end", + TeslaBleHttpProxy: "proxy", +} + type Command struct { Command string - Domain DomainType + Source CommandSourceType Vin string Body map[string]interface{} Response *models.ApiResponse } -// 'charge_state', 'climate_state', 'closures_state', 'drive_state', 'gui_settings', 'location_data', 'charge_schedule_data', 'preconditioning_schedule_data', 'vehicle_config', 'vehicle_state', 'vehicle_data_combo' var categoriesByName = map[string]vehicle.StateCategory{ - "charge_state": vehicle.StateCategoryCharge, - "climate_state": vehicle.StateCategoryClimate, - "drive": vehicle.StateCategoryDrive, - "closures_state": vehicle.StateCategoryClosures, - "charge-schedule": vehicle.StateCategoryChargeSchedule, - "precondition-schedule": vehicle.StateCategoryPreconditioningSchedule, - "tire-pressure": vehicle.StateCategoryTirePressure, - "media": vehicle.StateCategoryMedia, - "media-detail": vehicle.StateCategoryMediaDetail, - "software-update": vehicle.StateCategorySoftwareUpdate, - "parental-controls": vehicle.StateCategoryParentalControls, + "charge_state": vehicle.StateCategoryCharge, + "climate_state": vehicle.StateCategoryClimate, + // "drive_state": vehicle.StateCategoryDrive, + // "location_data": vehicle.StateCategoryLocation, + // "closures_state": vehicle.StateCategoryClosures, + // "charge_schedule_data": vehicle.StateCategoryChargeSchedule, + // "preconditioning_schedule_data": vehicle.StateCategoryPreconditioningSchedule, + + // Missing standard categories + // "gui_settings" + // "vehicle_config" + // "vehicle_state" + // "vehicle_data_combo" + + // Non-standard categories + // "tire_pressure": vehicle.StateCategoryTirePressure, + // "media": vehicle.StateCategoryMedia, + // "media_detail": vehicle.StateCategoryMediaDetail, + // "software_update": vehicle.StateCategorySoftwareUpdate, + // "parental_controls": vehicle.StateCategoryParentalControls, +} + +func (command *Command) Domain() DomainType { + switch command.Command { + case "body_controller_state": + fallthrough + case "wake_up": + return Domain.VCSEC + default: + return Domain.Infotainment + } + } func GetCategory(nameStr string) (vehicle.StateCategory, error) { diff --git a/internal/tesla/commands/commands.go b/internal/tesla/commands/commands.go index fc56620..8f68eb5 100644 --- a/internal/tesla/commands/commands.go +++ b/internal/tesla/commands/commands.go @@ -4,8 +4,6 @@ import ( "context" "encoding/json" "fmt" - "strconv" - "strings" "github.com/charmbracelet/log" "github.com/teslamotors/vehicle-command/pkg/protocol" @@ -13,204 +11,151 @@ import ( "github.com/teslamotors/vehicle-command/pkg/vehicle" "github.com/wimaha/TeslaBleHttpProxy/config" "github.com/wimaha/TeslaBleHttpProxy/internal/api/models" - "google.golang.org/protobuf/encoding/protojson" ) -var ExceptedCommands = []string{"vehicle_data", "auto_conditioning_start", "auto_conditioning_stop", "charge_port_door_open", "charge_port_door_close", "flash_lights", "wake_up", "set_charging_amps", "set_charge_limit", "charge_start", "charge_stop", "session_info", "honk_horn", "door_lock", "door_unlock", "set_sentry_mode"} -var ExceptedEndpoints = []string{"charge_state", "climate_state"} - func (command *Command) Send(ctx context.Context, car *vehicle.Vehicle) (shouldRetry bool, err error) { - switch command.Command { - case "auto_conditioning_start": - if err := car.ClimateOn(ctx); err != nil { - return true, fmt.Errorf("failed to start auto conditioning: %s", err) - } - case "auto_conditioning_stop": - if err := car.ClimateOff(ctx); err != nil { - return true, fmt.Errorf("failed to stop auto conditioning: %s", err) - } - case "charge_port_door_open": - if err := car.ChargePortOpen(ctx); err != nil { - return true, fmt.Errorf("failed to open charge port: %s", err) - } - case "charge_port_door_close": - if err := car.ChargePortClose(ctx); err != nil { - return true, fmt.Errorf("failed to close charge port: %s", err) - } - case "flash_lights": - if err := car.FlashLights(ctx); err != nil { - return true, fmt.Errorf("failed to flash lights: %s", err) - } - case "wake_up": - if err := car.Wakeup(ctx); err != nil { - return true, fmt.Errorf("failed to wake up car: %s", err) - } - case "honk_horn": - if err := car.HonkHorn(ctx); err != nil { - return true, fmt.Errorf("failed to honk horn %s", err) - } - case "door_lock": - if err := car.Lock(ctx); err != nil { - return true, fmt.Errorf("failed to lock %s", err) - } - case "door_unlock": - if err := car.Unlock(ctx); err != nil { - return true, fmt.Errorf("failed to unlock %s", err) - } - case "set_sentry_mode": - var on bool - switch v := command.Body["on"].(type) { - case bool: - on = v - case string: - if onBool, err := strconv.ParseBool(v); err == nil { - on = onBool - } else { - return false, fmt.Errorf("on parsing error: %s", err) + log.Debug("sending command", "command", command.Command, "source", command.Source, "vin", command.Vin) + if command.Source == CommandSource.TeslaBleHttpProxy { + switch command.Command { + case "session_info": + publicKey, err := protocol.LoadPublicKey(config.PublicKeyFile) + if err != nil { + return false, fmt.Errorf("failed to load public key: %s", err) } - default: - return false, fmt.Errorf("on missing in body") - } - if err := car.SetSentryMode(ctx, on); err != nil { - return true, fmt.Errorf("failed to set sentry mode %s", err) - } - case "charge_start": - if err := car.ChargeStart(ctx); err != nil { - if strings.Contains(err.Error(), "is_charging") { - //The car is already charging, so the command is somehow successfully executed. - log.Info("the car is already charging") - return false, nil - } else if strings.Contains(err.Error(), "complete") { - //The charging is completed, so the command is somehow successfully executed. - log.Info("the charging is completed") - return false, nil + + info, err := car.SessionInfo(ctx, publicKey, protocol.DomainVCSEC) + if err != nil { + return true, fmt.Errorf("failed session_info: %s", err) } - return true, fmt.Errorf("failed to start charge: %s", err) - } - case "charge_stop": - if err := car.ChargeStop(ctx); err != nil { - if strings.Contains(err.Error(), "not_charging") { - //The car has already stopped charging, so the command is somehow successfully executed. - log.Info("the car has already stopped charging") - return false, nil + fmt.Printf("%s\n", info) + case "add-key-request": + publicKey, err := protocol.LoadPublicKey(config.PublicKeyFile) + if err != nil { + return false, fmt.Errorf("failed to load public key: %s", err) } - return true, fmt.Errorf("failed to stop charge: %s", err) - } - case "set_charging_amps": - var chargingAmps int32 - switch v := command.Body["charging_amps"].(type) { - case float64: - chargingAmps = int32(v) - case string: - if chargingAmps64, err := strconv.ParseInt(v, 10, 32); err == nil { - chargingAmps = int32(chargingAmps64) + + if err := car.SendAddKeyRequest(ctx, publicKey, true, vcsec.KeyFormFactor_KEY_FORM_FACTOR_CLOUD_KEY); err != nil { + return true, fmt.Errorf("failed to add key: %s", err) } else { - return false, fmt.Errorf("charing Amps parsing error: %s", err) + log.Info(fmt.Sprintf("Sent add-key request to %s. Confirm by tapping NFC card on center console.", car.VIN())) } - default: - return false, fmt.Errorf("charing Amps missing in body") - } - if err := car.SetChargingAmps(ctx, chargingAmps); err != nil { - return true, fmt.Errorf("failed to set charging Amps to %d: %s", chargingAmps, err) - } - case "set_charge_limit": - var chargeLimit int32 - switch v := command.Body["percent"].(type) { - case float64: - chargeLimit = int32(v) - case string: - if chargeLimit64, err := strconv.ParseInt(v, 10, 32); err == nil { - chargeLimit = int32(chargeLimit64) - } else { - return false, fmt.Errorf("charing Amps parsing error: %s", err) + case "body_controller_state": + info, err := car.BodyControllerState(ctx) + if err != nil { + return true, fmt.Errorf("failed to get body controller state: %s", err) + } + converted := models.BodyControllerStateFromBle(info) + d, err := json.Marshal(converted) + if err != nil { + return true, fmt.Errorf("failed to marshal body-controller-state: %s", err) } + command.Response.Response = d default: - return false, fmt.Errorf("charing Amps missing in body") - } - if err := car.ChangeChargeLimit(ctx, chargeLimit); err != nil { - return true, fmt.Errorf("failed to set charge limit to %d %%: %s", chargeLimit, err) - } - case "session_info": - publicKey, err := protocol.LoadPublicKey(config.PublicKeyFile) - if err != nil { - return false, fmt.Errorf("failed to load public key: %s", err) + return false, fmt.Errorf("unrecognized proxy command: %s", command.Command) } + } else if command.Source == CommandSource.FleetVehicleEndpoint { + switch command.Command { + case "vehicle_data": + if command.Body == nil { + return false, fmt.Errorf("request body is nil") + } - info, err := car.SessionInfo(ctx, publicKey, protocol.DomainVCSEC) - if err != nil { - return true, fmt.Errorf("failed session_info: %s", err) - } - fmt.Printf("%s\n", info) - case "add-key-request": - publicKey, err := protocol.LoadPublicKey(config.PublicKeyFile) - if err != nil { - return false, fmt.Errorf("failed to load public key: %s", err) - } + endpoints, ok := command.Body["endpoints"].([]string) + if !ok { + return false, fmt.Errorf("missing or invalid 'endpoints' in request body") + } - if err := car.SendAddKeyRequest(ctx, publicKey, true, vcsec.KeyFormFactor_KEY_FORM_FACTOR_CLOUD_KEY); err != nil { - return true, fmt.Errorf("failed to add key: %s", err) - } else { - log.Info(fmt.Sprintf("Sent add-key request to %s. Confirm by tapping NFC card on center console.", car.VIN())) - } - case "vehicle_data": - if command.Body == nil { - return false, fmt.Errorf("request body is nil") - } + response := make(map[string]json.RawMessage) + for _, endpoint := range endpoints { + log.Debugf("get: %s", endpoint) + category, err := GetCategory(endpoint) + if err != nil { + return false, fmt.Errorf("unrecognized state category charge") + } + data, err := car.GetState(ctx, category) + if err != nil { + return true, fmt.Errorf("failed to get vehicle data: %s", err) + } - endpoints, ok := command.Body["endpoints"].([]string) - if !ok { - return false, fmt.Errorf("missing or invalid 'endpoints' in request body") - } + var converted interface{} + switch endpoint { + case "charge_state": + converted = models.ChargeStateFromBle(data) + case "climate_state": + converted = models.ClimateStateFromBle(data) + // case "drive_state": + // converted = models.DriveStateFromBle(data) + // case "location_data": + // converted = models.LocationDataFromBle(data) + // case "closures_state": + // converted = models.ClosuresStateFromBle(data) + // case "charge_schedule_data": + // converted = models.ChargeScheduleDataFromBle(data) + // case "preconditioning_schedule_data": + // converted = models.PreconditioningScheduleDataFromBle(data) + // case "tire_pressure": + // converted = models.TirePressureFromBle(data) + // case "media": + // converted = models.MediaFromBle(data) + // case "media_detail": + // converted = models.MediaDetailFromBle(data) + // case "software_update": + // converted = models.SoftwareUpdateFromBle(data) + // case "parental_controls": + // converted = models.ParentalControlsFromBle(data) + case "gui_settings": + fallthrough + case "vehicle_config": + fallthrough + case "vehicle_state": + fallthrough + case "vehicle_data_combo": + return false, fmt.Errorf("not supported via BLE %s", endpoint) + default: + return false, fmt.Errorf("unrecognized state category: %s", endpoint) + } + d, err := json.Marshal(converted) + if err != nil { + return true, fmt.Errorf("failed to marshal vehicle data: %s", err) + } - response := make(map[string]json.RawMessage) - for _, endpoint := range endpoints { - log.Debugf("get: %s", endpoint) - category, err := GetCategory(endpoint) - if err != nil { - return false, fmt.Errorf("unrecognized state category charge") - } - data, err := car.GetState(ctx, category) - if err != nil { - return true, fmt.Errorf("failed to get vehicle data: %s", err) + response[endpoint] = d } - /*d, err := protojson.Marshal(data) - if err != nil { - return true, fmt.Errorf("failed to marshal vehicle data: %s", err) - } - log.Debugf("data: %s", d)*/ - var converted interface{} - switch endpoint { - case "charge_state": - converted = models.ChargeStateFromBle(data) - case "climate_state": - converted = models.ClimateStateFromBle(data) - } - d, err := json.Marshal(converted) + responseJson, err := json.Marshal(response) if err != nil { - return true, fmt.Errorf("failed to marshal vehicle data: %s", err) + return false, fmt.Errorf("failed to marshal vehicle data: %s", err) } - - response[endpoint] = d - } - - responseJson, err := json.Marshal(response) - if err != nil { - return false, fmt.Errorf("failed to marshal vehicle data: %s", err) + command.Response.Response = responseJson + case "wake_up": + if err := car.Wakeup(ctx); err != nil { + return true, fmt.Errorf("failed to wake up vehicle: %s", err) + } + default: + return false, fmt.Errorf("unrecognized vehicle endpoint command: %s", command.Command) } - command.Response.Response = responseJson - case "body-controller-state": - info, err := car.BodyControllerState(ctx) - if err != nil { - return true, fmt.Errorf("failed to get body controller state: %s", err) + } else if command.Source == CommandSource.FleetVehicleCommands { + handler, ok := fleetVehicleCommands[command.Command] + if !ok { + return false, fmt.Errorf("unrecognized vehicle command: %s", command.Command) } - d, err := protojson.Marshal(info) - if err != nil { - return true, fmt.Errorf("failed to marshal body-controller-state: %s", err) + + // It should already be validated by the time it gets here + if handler.execute != nil { + if err := handler.execute(ctx, car, command.Body); err != nil { + if handler.checkError != nil { + err = handler.checkError(err) + } + + if err != nil { + return true, fmt.Errorf("failed to execute command: %s", err) + } + } + } else { + log.Warn("command not implemented: %s", command.Command) } - command.Response.Response = d - default: - return false, fmt.Errorf("unrecognized command: %s", command.Command) + } else { + log.Warn("unrecognized command source: %s", command.Source) } // everything fine diff --git a/internal/tesla/commands/commands_test.go b/internal/tesla/commands/commands_test.go deleted file mode 100644 index c1fc463..0000000 --- a/internal/tesla/commands/commands_test.go +++ /dev/null @@ -1,28 +0,0 @@ -package commands - -import ( - "context" - "testing" - - "github.com/teslamotors/vehicle-command/pkg/vehicle" -) - -func TestAllExceptedCommandsImplemented(t *testing.T) { - // Get all case statements from the Send method by creating a dummy command - // and checking which commands return "unrecognized command" - command := &Command{} - - for _, expectedCmd := range ExceptedCommands { - command.Command = expectedCmd - car := &vehicle.Vehicle{} - ctx := context.Background() - - // Try to find if command is implemented by checking if it returns - // "unrecognized command" error - _, err := command.Send(ctx, car) - - if err != nil && err.Error() == "unrecognized command: "+expectedCmd { - t.Errorf("Command %q is in ExceptedCommands but not implemented in Send method", expectedCmd) - } - } -} diff --git a/internal/tesla/commands/fleetVehicleCommands.go b/internal/tesla/commands/fleetVehicleCommands.go new file mode 100644 index 0000000..0890170 --- /dev/null +++ b/internal/tesla/commands/fleetVehicleCommands.go @@ -0,0 +1,898 @@ +package commands + +import ( + "context" + "fmt" + "math" + "strings" + "time" + + "github.com/charmbracelet/log" + "github.com/teslamotors/vehicle-command/pkg/protocol/protobuf/carserver" + "github.com/teslamotors/vehicle-command/pkg/vehicle" +) + +type commandArgs map[string]interface{} + +func (args commandArgs) validateString(key string, required bool) (string, error) { + if _, ok := args[key]; !ok { + if required { + return "", fmt.Errorf("missing '%s' in request body", key) + } else { + return "", nil + } + } + value, ok := args[key].(string) + if !ok { + return "", fmt.Errorf("expected '%s' to be a string", key) + } + return value, nil +} +func (args commandArgs) str(key string) string { + value, _ := args[key].(string) + return value +} + +func (args commandArgs) optStr(key string, def string) string { + if value, ok := args[key].(string); ok { + return value + } + return def +} +func (args commandArgs) validateBool(key string, required bool) (bool, error) { + if _, ok := args[key]; !ok { + if required { + return false, fmt.Errorf("missing '%s' in request body", key) + } else { + return false, nil + } + } + value, ok := args[key].(bool) + if !ok { + return false, fmt.Errorf("expected '%s' to be a boolean", key) + } + return value, nil +} +func (args commandArgs) bool(key string) bool { + value, _ := args[key].(bool) + return value +} +func (args commandArgs) optBool(key string, def bool) bool { + if value, ok := args[key].(bool); ok { + return value + } + return def +} +func (args commandArgs) validateInt(key string, required bool) (int, error) { + if _, ok := args[key]; !ok { + if required { + return 0, fmt.Errorf("missing '%s' in request body", key) + } else { + return 0, nil + } + } + value, ok := args[key].(int) + if !ok { + if value, ok := args[key].(float64); ok { + // Ensure that the float is actually an integer + if value != math.Trunc(value) || value < math.MinInt32 || value > math.MaxInt32 || math.IsNaN(value) { + return 0, fmt.Errorf("expected '%s' to be an integer", key) + } + args[key] = int(value) + return int(value), nil + } + return 0, fmt.Errorf("expected '%s' to be an integer", key) + } + return value, nil +} +func (args commandArgs) int(key string) int { + value, _ := args[key].(int) + return value +} +func (args commandArgs) optInt(key string, def int) int { + if value, ok := args[key].(float64); ok { + return int(value) + } + return def +} +func (args commandArgs) validateFloat(key string, required bool) (float64, error) { + if _, ok := args[key]; !ok { + if required { + return 0, fmt.Errorf("missing '%s' in request body", key) + } else { + return 0, nil + } + } + value, ok := args[key].(float64) + if !ok { + value, ok := args[key].(int) + if !ok { + return 0, fmt.Errorf("expected '%s' to be a number", key) + } + args[key] = float64(value) + return float64(value), nil + } + return value, nil +} +func (args commandArgs) float(key string) float64 { + value, _ := args[key].(float64) + return value +} +func (args commandArgs) float32(key string) float32 { + return float32(args.float(key)) +} +func (args commandArgs) optFloat(key string, def float64) float64 { + if value, ok := args[key].(float64); ok { + return value + } + return def +} + +type fleetVehicleCommandHandler struct { + validate func(commandArgs) error + execute func(context.Context, *vehicle.Vehicle, commandArgs) error + checkError func(error) error +} + +// TODO: Eventually replace all of this with ExtractCommandAction +// from /~https://github.com/teslamotors/vehicle-command/blob/main/pkg/proxy/command.go +// however, for now, that implementation is missing some stuff and is not the same as Fleet API (for now) +// Progress: /~https://github.com/teslamotors/vehicle-command/issues/188 +var fleetVehicleCommands = map[string]fleetVehicleCommandHandler{ + "FIXME/shut_up_warnings_for_unused_opt_methods": { + validate: func(args commandArgs) error { + // FIXME: Some of these methods are not used yet, + // but might be in the future so we keep them here + // to avoid warnings about unused methods + args.optBool("", false) + args.optInt("", 0) + args.optStr("", "") + args.optFloat("", 0) + return fmt.Errorf("not intended to be used") + }, + }, + "actuate_trunk": { + validate: func(args commandArgs) error { + which_trunk, err := args.validateString("which_trunk", true) + if err != nil { + return err + } + if which_trunk != "front" && which_trunk != "rear" { + return fmt.Errorf("invalid 'which_trunk' value: %s", which_trunk) + } + + return nil + }, + execute: func(ctx context.Context, car *vehicle.Vehicle, args commandArgs) error { + switch args.str("which_trunk") { + case "front": + return car.OpenFrunk(ctx) + case "rear": + return car.ActuateTrunk(ctx) + default: + return fmt.Errorf("invalid 'which_trunk' value: %s", args["which_trunk"]) + } + }, + }, + "add_charge_schedule": { + validate: func(args commandArgs) error { + // TODO: implement + return fmt.Errorf("not implemented") + }, + }, + "add_precondition_schedule": { + validate: func(args commandArgs) error { + // TODO: implement + return fmt.Errorf("not implemented") + }, + }, + "adjust_volume": { + validate: func(args commandArgs) error { + volume, err := args.validateFloat("volume", true) + if err != nil { + return err + } + + if volume < 0 || volume > 11 { + return fmt.Errorf("invalid 'volume' (should be in [0, 11])") + } + return nil + }, + execute: func(ctx context.Context, car *vehicle.Vehicle, args commandArgs) error { + volume := args.float32("volume") + if volume > 10 { + log.Warn("volume greater than 10 can not be set via BLE, clamping to 10") + volume = 10 + } + return car.SetVolume(ctx, volume) + }, + }, + "auto_conditioning_start": { + execute: func(ctx context.Context, car *vehicle.Vehicle, args commandArgs) error { + return car.ClimateOn(ctx) + }, + }, + "auto_conditioning_stop": { + execute: func(ctx context.Context, car *vehicle.Vehicle, args commandArgs) error { + return car.ClimateOff(ctx) + }, + }, + "cancel_software_update": { + execute: func(ctx context.Context, car *vehicle.Vehicle, args commandArgs) error { + return car.CancelSoftwareUpdate(ctx) + }, + }, + "charge_max_range": { + execute: func(ctx context.Context, car *vehicle.Vehicle, args commandArgs) error { + return car.ChargeMaxRange(ctx) + }, + }, + "charge_port_door_close": { + execute: func(ctx context.Context, car *vehicle.Vehicle, args commandArgs) error { + return car.ChargePortClose(ctx) + }, + }, + "charge_port_door_open": { + execute: func(ctx context.Context, car *vehicle.Vehicle, args commandArgs) error { + return car.ChargePortOpen(ctx) + }, + }, + "charge_standard": { + execute: func(ctx context.Context, car *vehicle.Vehicle, args commandArgs) error { + return car.ChargeStandardRange(ctx) + }, + }, + "charge_start": { + execute: func(ctx context.Context, car *vehicle.Vehicle, args commandArgs) error { + return car.ChargeStart(ctx) + }, + checkError: func(err error) error { + if strings.Contains(err.Error(), "is_charging") { + return nil + } else if strings.Contains(err.Error(), "complete") { + return nil + } + return err + }, + }, + "charge_stop": { + execute: func(ctx context.Context, car *vehicle.Vehicle, args commandArgs) error { + return car.ChargeStop(ctx) + }, + checkError: func(err error) error { + if strings.Contains(err.Error(), "not_charging") { + return nil + } + return err + }, + }, + "clear_pin_to_drive_admin": { + execute: func(ctx context.Context, car *vehicle.Vehicle, args commandArgs) error { + return car.SetPINToDrive(ctx, false, "") + }, + }, + "door_lock": { + execute: func(ctx context.Context, car *vehicle.Vehicle, args commandArgs) error { + return car.Lock(ctx) + }, + }, + "door_unlock": { + execute: func(ctx context.Context, car *vehicle.Vehicle, args commandArgs) error { + return car.Unlock(ctx) + }, + }, + "erase_user_data": { + execute: func(ctx context.Context, car *vehicle.Vehicle, args commandArgs) error { + return car.EraseGuestData(ctx) + }, + }, + "flash_lights": { + execute: func(ctx context.Context, car *vehicle.Vehicle, args commandArgs) error { + return car.FlashLights(ctx) + }, + }, + "guest_mode": { + validate: func(args commandArgs) error { + if _, err := args.validateBool("enable", true); err != nil { + return err + } + return nil + }, + execute: func(ctx context.Context, car *vehicle.Vehicle, args commandArgs) error { + return car.SetGuestMode(ctx, args.bool("enable")) + }, + }, + "honk_horn": { + execute: func(ctx context.Context, car *vehicle.Vehicle, args commandArgs) error { + return car.HonkHorn(ctx) + }, + }, + "media_next_fav": { + validate: func(args commandArgs) error { + return fmt.Errorf("not implemented") // TODO: /~https://github.com/teslamotors/vehicle-command/pull/356 + }, + }, + "media_next_track": { + validate: func(args commandArgs) error { + return fmt.Errorf("not implemented") // TODO: /~https://github.com/teslamotors/vehicle-command/pull/356 + }, + }, + "media_prev_fav": { + validate: func(args commandArgs) error { + return fmt.Errorf("not implemented") // TODO: /~https://github.com/teslamotors/vehicle-command/pull/356 + }, + }, + "media_prev_track": { + validate: func(args commandArgs) error { + return fmt.Errorf("not implemented") // TODO: /~https://github.com/teslamotors/vehicle-command/pull/356 + }, + }, + "media_toggle_playback": { + execute: func(ctx context.Context, car *vehicle.Vehicle, args commandArgs) error { + return car.ToggleMediaPlayback(ctx) + }, + }, + "media_volume_up": { + validate: func(args commandArgs) error { + return fmt.Errorf("not implemented") // TODO: /~https://github.com/teslamotors/vehicle-command/pull/356 + }, + }, + "media_volume_down": { + validate: func(args commandArgs) error { + return fmt.Errorf("not implemented") // TODO: /~https://github.com/teslamotors/vehicle-command/pull/356 + }, + }, + "navigation_gps_request": { + validate: func(args commandArgs) error { + return fmt.Errorf("not implemented") // TODO: /~https://github.com/teslamotors/vehicle-command/issues/334 + }, + }, + "navigation_request": { + validate: func(args commandArgs) error { + return fmt.Errorf("not implemented") // TODO: /~https://github.com/teslamotors/vehicle-command/issues/334 + }, + }, + "navigation_sc_request": { + validate: func(args commandArgs) error { + return fmt.Errorf("not implemented") // TODO: /~https://github.com/teslamotors/vehicle-command/issues/334 + }, + }, + "navigation_waypoints_request": { + validate: func(args commandArgs) error { + return fmt.Errorf("not implemented") // TODO: /~https://github.com/teslamotors/vehicle-command/issues/334 + }, + }, + "remote_auto_seat_climate_request": { + validate: func(args commandArgs) error { + if _, err := args.validateBool("auto_climate_on", true); err != nil { + return err + } + + position, err := args.validateInt("auto_seat_position", true) + if err != nil { + return err + } + + if _, ok := carserver.AutoSeatClimateAction_AutoSeatPosition_E_name[int32(position)]; !ok { + return fmt.Errorf("invalid 'auto_seat_position' value: %d", position) + } + return nil + }, + execute: func(ctx context.Context, car *vehicle.Vehicle, args commandArgs) error { + autoClimateOn := args.bool("auto_climate_on") + position := args.int("auto_seat_position") + var seat vehicle.SeatPosition + switch carserver.AutoSeatClimateAction_AutoSeatPosition_E(position) { + case carserver.AutoSeatClimateAction_AutoSeatPosition_FrontLeft: + seat = vehicle.SeatFrontLeft + case carserver.AutoSeatClimateAction_AutoSeatPosition_FrontRight: + seat = vehicle.SeatFrontRight + default: + seat = vehicle.SeatUnknown + } + return car.AutoSeatAndClimate(ctx, []vehicle.SeatPosition{seat}, autoClimateOn) + }, + }, + "remote_auto_steering_wheel_heat_climate_request": { + validate: func(args commandArgs) error { + return fmt.Errorf("not supported via BLE") + }, + }, + "remote_boombox": { + validate: func(args commandArgs) error { + return fmt.Errorf("not supported via BLE") + }, + }, + "remote_seat_cooler_request": { + validate: func(args commandArgs) error { + level, err := args.validateInt("seat_cooler_level", true) + if err != nil { + return err + } + if level < int(vehicle.LevelOff) || level > int(vehicle.LevelHigh) { + return fmt.Errorf("invalid 'seat_cooler_level' value: %d", level) + } + + position, err := args.validateInt("seat_position", true) + if err != nil { + return err + } + if position != int(vehicle.SeatFrontLeft) && position != int(vehicle.SeatFrontRight) { + return fmt.Errorf("invalid 'seat_position' value: %d", position) + } + return nil + + }, + execute: func(ctx context.Context, car *vehicle.Vehicle, args commandArgs) error { + level := args.int("seat_cooler_level") + position := args.int("seat_position") + return car.SetSeatCooler(ctx, vehicle.Level(level), vehicle.SeatPosition(position)) + }, + }, + "remote_seat_heater_request": { + validate: func(args commandArgs) error { + heater, err := args.validateInt("heater", true) + if err != nil { + return err + } + if heater < int(vehicle.SeatFrontLeft) || heater > int(vehicle.SeatThirdRowRight) { + return fmt.Errorf("invalid 'heater' value: %d", heater) + } + + level, err := args.validateInt("level", true) + if err != nil { + return err + } + if level < int(vehicle.LevelOff) || level > int(vehicle.LevelHigh) { + return fmt.Errorf("invalid 'level' value: %d", level) + } + return nil + }, + execute: func(ctx context.Context, car *vehicle.Vehicle, args commandArgs) error { + heater := args.int("heater") + level := args.int("level") + return car.SetSeatHeater(ctx, map[vehicle.SeatPosition]vehicle.Level{vehicle.SeatPosition(heater): vehicle.Level(level)}) + }, + }, + "remote_start_drive": { + execute: func(ctx context.Context, car *vehicle.Vehicle, args commandArgs) error { + return car.RemoteDrive(ctx) + }, + }, + "remote_steering_wheel_heat_level_request": { + validate: func(args commandArgs) error { + return fmt.Errorf("not supported via BLE") + }, + }, + "remote_steering_wheel_heater_request": { + validate: func(args commandArgs) error { + if _, err := args.validateBool("on", true); err != nil { + return err + } + + return nil + }, + execute: func(ctx context.Context, car *vehicle.Vehicle, args commandArgs) error { + return car.SetSteeringWheelHeater(ctx, args.bool("on")) + }, + }, + "remove_charge_schedule": { + validate: func(args commandArgs) error { + // TODO: implement + return fmt.Errorf("not implemented") + }, + }, + "remove_precondition_schedule": { + validate: func(args commandArgs) error { + // TODO: implement + return fmt.Errorf("not implemented") + }, + }, + "reset_pin_to_drive_pin": { + execute: func(ctx context.Context, car *vehicle.Vehicle, args commandArgs) error { + return car.ResetPIN(ctx) + }, + }, + "reset_valet_pin": { + execute: func(ctx context.Context, car *vehicle.Vehicle, args commandArgs) error { + return car.ResetValetPin(ctx) + }, + }, + "schedule_software_update": { + validate: func(args commandArgs) error { + sec, err := args.validateFloat("offset_sec", true) + if err != nil { + return err + } + if sec < 0 { + return fmt.Errorf("invalid 'offset_sec' value: %f", sec) + } + return nil + }, + execute: func(ctx context.Context, car *vehicle.Vehicle, args commandArgs) error { + sec := args.float("offset_sec") + return car.ScheduleSoftwareUpdate(ctx, time.Duration(sec)*time.Second) + }, + }, + "set_bioweapon_mode": { + validate: func(args commandArgs) error { + if _, err := args.validateBool("manual_override", true); err != nil { + return err + } + if _, err := args.validateBool("on", true); err != nil { + return err + } + return nil + }, + execute: func(ctx context.Context, car *vehicle.Vehicle, args commandArgs) error { + return car.SetBioweaponDefenseMode(ctx, args.bool("on"), args.bool("manual_override")) + }, + }, + "set_cabin_overheat_protection": { + validate: func(args commandArgs) error { + if _, err := args.validateBool("fan_only", true); err != nil { + return err + } + if _, err := args.validateBool("on", true); err != nil { + return err + } + + return nil + }, + execute: func(ctx context.Context, car *vehicle.Vehicle, args commandArgs) error { + on := args.bool("on") + onlyFans := args.bool("fan_only") + return car.SetCabinOverheatProtection(ctx, on, onlyFans) + }, + }, + "set_charge_limit": { + validate: func(args commandArgs) error { + percent, err := args.validateInt("percent", true) + if err != nil { + return err + } + if percent < 50 || percent > 100 { + return fmt.Errorf("invalid 'percent' value: %d", percent) + } + return nil + }, + execute: func(ctx context.Context, car *vehicle.Vehicle, args commandArgs) error { + return car.ChangeChargeLimit(ctx, int32(args.int("percent"))) + }, + }, + "set_charging_amps": { + validate: func(args commandArgs) error { + chargingAmps, err := args.validateInt("charging_amps", true) + if err != nil { + return err + } + if chargingAmps < 0 || chargingAmps > 48 { + return fmt.Errorf("invalid 'charging_amps' value: %d", chargingAmps) + } + return nil + }, + execute: func(ctx context.Context, car *vehicle.Vehicle, args commandArgs) error { + return car.SetChargingAmps(ctx, int32(args.int("charging_amps"))) + }, + }, + "set_climate_keeper_mode": { + validate: func(args commandArgs) error { + mode, err := args.validateInt("climate_keeper_mode", true) + if err != nil { + return err + } + if mode < int(vehicle.ClimateKeeperModeOff) || mode > int(vehicle.ClimateKeeperModeCamp) { + return fmt.Errorf("invalid 'climate_keeper_mode' value: %d", mode) + } + return nil + }, + execute: func(ctx context.Context, car *vehicle.Vehicle, args commandArgs) error { + return car.SetClimateKeeperMode(ctx, vehicle.ClimateKeeperMode(args.int("climate_keeper_mode")), true) + }, + }, + "set_cop_temp": { + validate: func(args commandArgs) error { + copTemp, err := args.validateInt("cop_temp", true) + if err != nil { + return err + } + // NOTE: The vehicle.Level this function requires starts at 0 for low, + // however we want to start with 1 for Low in Cop temp (0 is unspecified) + // (check carserver.ClimateState_CopActivationTemp) + copTemp += 1 + + if copTemp < int(vehicle.LevelLow) || copTemp > int(vehicle.LevelHigh) { + return fmt.Errorf("invalid 'cop_temp' value: %d", copTemp) + } + + return nil + }, + execute: func(ctx context.Context, car *vehicle.Vehicle, args commandArgs) error { + return car.SetCabinOverheatProtectionTemperature(ctx, vehicle.Level(args.int("cop_temp")+1)) + }, + }, + "set_pin_to_drive": { + validate: func(args commandArgs) error { + if _, err := args.validateBool("enable", true); err != nil { + return err + } + + password, err := args.validateString("password", true) + if err != nil { + return err + } + if len(password) != 4 { + return fmt.Errorf("invalid 'password' length: %d", len(password)) + } + return nil + }, + execute: func(ctx context.Context, car *vehicle.Vehicle, args commandArgs) error { + return car.SetPINToDrive(ctx, args.bool("enable"), args.str("password")) + }, + }, + "set_preconditioning_max": { + validate: func(args commandArgs) error { + if _, err := args.validateBool("on", true); err != nil { + return err + } + if _, err := args.validateBool("manual_override", true); err != nil { + return err + } + + return nil + }, + execute: func(ctx context.Context, car *vehicle.Vehicle, args commandArgs) error { + return car.SetPreconditioningMax(ctx, args.bool("on"), args.bool("manual_override")) + }, + }, + "set_scheduled_charging": { + validate: func(args commandArgs) error { + if _, err := args.validateBool("enable", true); err != nil { + return err + } + + time, err := args.validateFloat("time", true) + if err != nil { + return err + } + if time < 0 || time > 24*60-1 { + return fmt.Errorf("invalid 'time' value: %f", time) + } + + return nil + }, + execute: func(ctx context.Context, car *vehicle.Vehicle, args commandArgs) error { + return car.ScheduleCharging(ctx, args.bool("enable"), time.Duration(args.float("time"))*time.Minute) + }, + }, + "set_scheduled_departure": { + validate: func(args commandArgs) error { + // TODO: implement + return fmt.Errorf("not implemented") + }, + }, + "set_sentry_mode": { + validate: func(args commandArgs) error { + if _, err := args.validateBool("on", true); err != nil { + return err + } + return nil + }, + execute: func(ctx context.Context, car *vehicle.Vehicle, args commandArgs) error { + return car.SetSentryMode(ctx, args.bool("on")) + }, + }, + "set_temps": { + validate: func(args commandArgs) error { + // NOTE: The temp is always in Celsius, regardless of the car's region + if _, err := args.validateFloat("driver_temp", true); err != nil { + return err + } + if _, err := args.validateFloat("passenger_temp", true); err != nil { + return err + } + + return nil + }, + execute: func(ctx context.Context, car *vehicle.Vehicle, args commandArgs) error { + driverTemp := args.float32("driver_temp") + passengerTemp := args.float32("passenger_temp") + return car.ChangeClimateTemp(ctx, driverTemp, passengerTemp) + }, + }, + "set_valet_mode": { + validate: func(args commandArgs) error { + if _, err := args.validateBool("on", true); err != nil { + return err + } + + password, err := args.validateString("password", true) + if err != nil { + return err + } + if len(password) != 4 { + return fmt.Errorf("invalid 'password' length: %d", len(password)) + } + return nil + }, + execute: func(ctx context.Context, car *vehicle.Vehicle, args commandArgs) error { + if args.bool("on") { + return car.EnableValetMode(ctx, args.str("password")) + } else { + return car.DisableValetMode(ctx) + } + }, + }, + "set_vehicle_name": { + validate: func(args commandArgs) error { + if _, err := args.validateString("vehicle_name", true); err != nil { + return err + } + return nil + }, + execute: func(ctx context.Context, car *vehicle.Vehicle, args commandArgs) error { + return car.SetVehicleName(ctx, args.str("vehicle_name")) + }, + }, + "speed_limit_activate": { + validate: func(args commandArgs) error { + pin, err := args.validateString("pin", true) + if err != nil { + return err + } + if len(pin) != 4 { + return fmt.Errorf("invalid 'pin' length: %d", len(pin)) + } + + return nil + }, + execute: func(ctx context.Context, car *vehicle.Vehicle, args commandArgs) error { + return car.ActivateSpeedLimit(ctx, args.str("pin")) + }, + }, + "speed_limit_clear_pin": { + validate: func(args commandArgs) error { + pin, err := args.validateString("pin", true) + if err != nil { + return err + } + if len(pin) != 4 { + return fmt.Errorf("invalid 'pin' length: %d", len(pin)) + } + + return nil + }, + execute: func(ctx context.Context, car *vehicle.Vehicle, args commandArgs) error { + return car.ClearSpeedLimitPIN(ctx, args.str("pin")) + }, + }, + "speed_limit_clear_pin_admin": { + validate: func(args commandArgs) error { + return fmt.Errorf("not supported via BLE") + }, + }, + "speed_limit_deactivate": { + validate: func(args commandArgs) error { + pin, err := args.validateString("pin", true) + if err != nil { + return err + } + if len(pin) != 4 { + return fmt.Errorf("invalid 'pin' length: %d", len(pin)) + } + + return nil + }, + execute: func(ctx context.Context, car *vehicle.Vehicle, args commandArgs) error { + return car.DeactivateSpeedLimit(ctx, args.str("pin")) + }, + }, + "speed_limit_set_limit": { + validate: func(args commandArgs) error { + limitMph, err := args.validateFloat("limit_mph", true) + if err != nil { + return err + } + if limitMph < 50 || limitMph > 90 { + return fmt.Errorf("invalid 'limit_mph' value: %f", limitMph) + } + + return nil + }, + execute: func(ctx context.Context, car *vehicle.Vehicle, args commandArgs) error { + return car.SpeedLimitSetLimitMPH(ctx, args.float("limit_mph")) + }, + }, + "sun_roof_control": { + validate: func(args commandArgs) error { + // https://tesla-api.timdorr.com/vehicle/commands/sunroof#post-api-1-vehicles-id-command-sun_roof_control + // TODO: implement - car.ChangeSunroofState + return fmt.Errorf("not implemented") + }, + }, + "trigger_homelink": { + validate: func(args commandArgs) error { + lat, err := args.validateFloat("lat", false) + if err != nil { + return err + } + if lat < -90 || lat > 90 { + return fmt.Errorf("invalid 'lat' value: %f", lat) + } + + lon, err := args.validateFloat("lon", false) + if err != nil { + return err + } + if lon < -180 || lon > 180 { + return fmt.Errorf("invalid 'lon' value: %f", lon) + } + + // Offical API requires token, but it's not needed here + // so we just validate it for completeness and to avoid errors + if _, ok := args.validateString("token", false); ok != nil { + return err + } + + return nil + }, + execute: func(ctx context.Context, car *vehicle.Vehicle, args commandArgs) error { + return car.TriggerHomelink(ctx, args.float32("lat"), args.float32("lon")) + }, + }, + "upcoming_calendar_entries": { + validate: func(args commandArgs) error { + return fmt.Errorf("not implemented") + }, + }, + "window_control": { + validate: func(args commandArgs) error { + // https://tesla-api.timdorr.com/vehicle/commands/windows#post-api-1-vehicles-id-command-window_control + // lat and lon values must be near the current location of the car for + // close operation to succeed. For vent, the lat and lon values are ignored, + // and may both be 0 (which has been observed from the app itself). + state, err := args.validateString("command", true) + if err != nil { + return err + } + // In actuallity, the lat and lon values are not required for any operation + // but we still validate them here for completeness and to avoid errors + if _, err := args.validateFloat("lat", false); err != nil { + return err + } + if _, err := args.validateFloat("lon", false); err != nil { + return err + } + switch state { + case "vent": + fallthrough + case "close": + return nil + default: + return fmt.Errorf("invalid 'command' value: %s", args["command"]) + } + }, + execute: func(ctx context.Context, car *vehicle.Vehicle, args commandArgs) error { + switch args.str("command") { + case "vent": + return car.VentWindows(ctx) + case "close": + return car.CloseWindows(ctx) + default: + return fmt.Errorf("invalid 'command' value: %s", args["command"]) + } + }, + }, +} + +func ValidateFleetVehicleCommand(command string, body map[string]interface{}) error { + handler, ok := fleetVehicleCommands[command] + if !ok { + return fmt.Errorf("unrecognized vehicle command: %s", command) + } + + if handler.validate != nil { + return handler.validate(body) + } + return nil +} From 275f34bb81aa698ebb2444eb36e9b18468581933 Mon Sep 17 00:00:00 2001 From: Lenart Kos Date: Thu, 23 Jan 2025 23:25:10 +0100 Subject: [PATCH 04/41] Revert BLE connection timeout from 290 seconds (testing) to 29 seconds --- internal/ble/control/control.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/internal/ble/control/control.go b/internal/ble/control/control.go index d0a80d6..9e3f507 100644 --- a/internal/ble/control/control.go +++ b/internal/ble/control/control.go @@ -345,7 +345,7 @@ func (bc *BleControl) TryConnectToVehicle(ctx context.Context, firstCommand *com func (bc *BleControl) operateConnection(car *vehicle.Vehicle, firstCommand *commands.Command) *commands.Command { log.Debug("operating connection ...") defer log.Debug("operating connection done") - connectionCtx, cancel := context.WithTimeout(context.Background(), 290*time.Second) + connectionCtx, cancel := context.WithTimeout(context.Background(), 29*time.Second) defer cancel() defer func() { bc.operatedBeacon = nil }() From 82057ed4672f2d000e9062937b25ff8287c958a8 Mon Sep 17 00:00:00 2001 From: Lenart Kos Date: Mon, 27 Jan 2025 20:09:21 +0100 Subject: [PATCH 05/41] Add missing vehicle state conversion --- internal/api/models/states.go | 102 ------------------- internal/api/models/statesConverter.go | 129 ++++++++++++++++++++++--- internal/tesla/commands/command.go | 24 ++--- internal/tesla/commands/commands.go | 40 ++++---- 4 files changed, 149 insertions(+), 146 deletions(-) delete mode 100644 internal/api/models/states.go diff --git a/internal/api/models/states.go b/internal/api/models/states.go deleted file mode 100644 index 5d32e73..0000000 --- a/internal/api/models/states.go +++ /dev/null @@ -1,102 +0,0 @@ -package models - -// ChargeState contains the current charge states that exist within the vehicle. -type ChargeState struct { - Timestamp int64 `json:"timestamp"` // - ChargingState string `json:"charging_state"` // - ChargeLimitSoc int32 `json:"charge_limit_soc"` // - ChargeLimitSocStd int32 `json:"charge_limit_soc_std"` // - ChargeLimitSocMin int32 `json:"charge_limit_soc_min"` // - ChargeLimitSocMax int32 `json:"charge_limit_soc_max"` // - BatteryHeaterOn bool `json:"battery_heater_on"` // - NotEnoughPowerToHeat bool `json:"not_enough_power_to_heat"` // - MaxRangeChargeCounter int32 `json:"max_range_charge_counter"` // - FastChargerPresent bool `json:"fast_charger_present"` // - FastChargerType string `json:"fast_charger_type"` // - BatteryRange float32 `json:"battery_range"` // - EstBatteryRange float32 `json:"est_battery_range"` // - IdealBatteryRange float32 `json:"ideal_battery_range"` // - BatteryLevel int32 `json:"battery_level"` // - UsableBatteryLevel int32 `json:"usable_battery_level"` // - ChargeEnergyAdded float32 `json:"charge_energy_added"` // - ChargeMilesAddedRated float32 `json:"charge_miles_added_rated"` // - ChargeMilesAddedIdeal float32 `json:"charge_miles_added_ideal"` // - ChargerVoltage int32 `json:"charger_voltage"` // - ChargerPilotCurrent int32 `json:"charger_pilot_current"` // - ChargerActualCurrent int32 `json:"charger_actual_current"` // - ChargerPower int32 `json:"charger_power"` // - TripCharging bool `json:"trip_charging"` // - ChargeRate float32 `json:"charge_rate"` // - ChargePortDoorOpen bool `json:"charge_port_door_open"` // - ScheduledChargingMode string `json:"scheduled_charging_mode"` // - ScheduledDepatureTime int64 `json:"scheduled_departure_time"` // - ScheduledDepatureTimeMinutes uint32 `json:"scheduled_departure_time_minutes"` // - SuperchargerSessionTripPlanner bool `json:"supercharger_session_trip_planner"` // - ScheduledChargingStartTime uint64 `json:"scheduled_charging_start_time"` // - ScheduledChargingPending bool `json:"scheduled_charging_pending"` // - UserChargeEnableRequest interface{} `json:"user_charge_enable_request"` // - ChargeEnableRequest bool `json:"charge_enable_request"` // - ChargerPhases int32 `json:"charger_phases"` // - ChargePortLatch string `json:"charge_port_latch"` // - ChargeCurrentRequest int32 `json:"charge_current_request"` // - ChargeCurrentRequestMax int32 `json:"charge_current_request_max"` // - ChargeAmps int32 `json:"charge_amps"` // - OffPeakChargingEnabled bool `json:"off_peak_charging_enabled"` // - OffPeakChargingTimes string `json:"off_peak_charging_times"` // - OffPeakHoursEndTime uint32 `json:"off_peak_hours_end_time"` // - PreconditioningEnabled bool `json:"preconditioning_enabled"` // - PreconditioningTimes string `json:"preconditioning_times"` // - ManagedChargingActive bool `json:"managed_charging_active"` // - ManagedChargingUserCanceled bool `json:"managed_charging_user_canceled"` // - ManagedChargingStartTime interface{} `json:"managed_charging_start_time"` // - ChargePortcoldWeatherMode bool `json:"charge_port_cold_weather_mode"` // - ChargePortColor string `json:"charge_port_color"` // - ConnChargeCable string `json:"conn_charge_cable"` // - FastChargerBrand string `json:"fast_charger_brand"` // - MinutesToFullCharge int32 `json:"minutes_to_full_charge"` // -} - -// ClimateState contains the current climate states available from the vehicle. -type ClimateState struct { - Timestamp int64 `json:"timestamp"` // - AllowCabinOverheatProtection bool `json:"allow_cabin_overheat_protection"` // - AutoSeatClimateLeft bool `json:"auto_seat_climate_left"` // - AutoSeatClimateRight bool `json:"auto_seat_climate_right"` // - AutoSteeringWheelHeat bool `json:"auto_steering_wheel_heat"` // - BioweaponMode bool `json:"bioweapon_mode"` // - CabinOverheatProtection string `json:"cabin_overheat_protection"` // - CabinOverheatProtectionActivelyCooling bool `json:"cabin_overheat_protection_actively_cooling"` // - CopActivationTemperature string `json:"cop_activation_temperature"` // - InsideTemp float32 `json:"inside_temp"` // - OutsideTemp float32 `json:"outside_temp"` // - DriverTempSetting float32 `json:"driver_temp_setting"` // - PassengerTempSetting float32 `json:"passenger_temp_setting"` // - LeftTempDirection int32 `json:"left_temp_direction"` // - RightTempDirection int32 `json:"right_temp_direction"` // - IsAutoConditioningOn bool `json:"is_auto_conditioning_on"` // - IsFrontDefrosterOn bool `json:"is_front_defroster_on"` // - IsRearDefrosterOn bool `json:"is_rear_defroster_on"` // - FanStatus int32 `json:"fan_status"` // - HvacAutoRequest string `json:"hvac_auto_request"` // - IsClimateOn bool `json:"is_climate_on"` // - MinAvailTemp float32 `json:"min_avail_temp"` // - MaxAvailTemp float32 `json:"max_avail_temp"` // - SeatHeaterLeft int32 `json:"seat_heater_left"` // - SeatHeaterRight int32 `json:"seat_heater_right"` // - SeatHeaterRearLeft int32 `json:"seat_heater_rear_left"` // - SeatHeaterRearRight int32 `json:"seat_heater_rear_right"` // - SeatHeaterRearCenter int32 `json:"seat_heater_rear_center"` // - SeatHeaterRearRightBack int32 `json:"seat_heater_rear_right_back"` - SeatHeaterRearLeftBack int32 `json:"seat_heater_rear_left_back"` - SteeringWheelHeatLevel int32 `json:"steering_wheel_heat_level"` // - SteeringWheelHeater bool `json:"steering_wheel_heater"` // - SupportsFanOnlyCabinOverheatProtection bool `json:"supports_fan_only_cabin_overheat_protection"` // - BatteryHeater bool `json:"battery_heater"` // - BatteryHeaterNoPower interface{} `json:"battery_heater_no_power"` // - ClimateKeeperMode string `json:"climate_keeper_mode"` // - DefrostMode string `json:"defrost_mode"` // - IsPreconditioning bool `json:"is_preconditioning"` // - RemoteHeaterControlEnabled bool `json:"remote_heater_control_enabled"` // - SideMirrorHeaters bool `json:"side_mirror_heaters"` // - WiperBladeHeater bool `json:"wiper_blade_heater"` // -} diff --git a/internal/api/models/statesConverter.go b/internal/api/models/statesConverter.go index 098b8ef..c840c3e 100644 --- a/internal/api/models/statesConverter.go +++ b/internal/api/models/statesConverter.go @@ -1,10 +1,15 @@ package models import ( + "encoding/json" + "regexp" "strings" + "time" "github.com/teslamotors/vehicle-command/pkg/protocol/protobuf/carserver" "github.com/teslamotors/vehicle-command/pkg/protocol/protobuf/vcsec" + "google.golang.org/protobuf/encoding/protojson" + "google.golang.org/protobuf/proto" ) func flatten(s string) any { @@ -155,15 +160,115 @@ MISSING SmartPreconditioning bool `json:"smart_preconditioning"` */ -// func ChargeStateFromBle(vehicleData *carserver.VehicleData) map[string]interface{} {} -// func ClimateStateFromBle(vehicleData *carserver.VehicleData) map[string]interface{} {) -// func DriveStateFromBle(vehicleData *carserver.VehicleData) map[string]interface{} { -// func LocationDataFromBle(vehicleData *carserver.VehicleData) map[string]interface{} {) -// func ClosuresStateFromBle(vehicleData *carserver.VehicleData) map[string]interface{} {) -// func ChargeScheduleDataFromBle(vehicleData *carserver.VehicleData) map[string]interface{} {) -// func PreconditioningScheduleDataFromBle(vehicleData *carserver.VehicleData) map[string]interface{} {) -// func TirePressureFromBle(vehicleData *carserver.VehicleData) map[string]interface{} {) -// func MediaFromBle(vehicleData *carserver.VehicleData) map[string]interface{} { -// func MediaDetailFromBle(vehicleData *carserver.VehicleData) map[string]interface{} { -// func SoftwareUpdateFromBle(vehicleData *carserver.VehicleData) map[string]interface{} {) -// func ParentalControlsFromBle(vehicleData *carserver.VehicleData) map[string]interface{} {) +// "Good enough" implementation of converting a protobuf message to a map[string]interface{} +// This is a workaround for now until we have a better solution or do manual conversion +// for each message type. It converts the protobuf message to JSON, then to a map and +// detects objects to flatten, camel case to snake case and converts time to unix timestamp. +// However, it is not perfect since `omitempty` fields are removed and some fields are +// missnamed (e.g. `OdometerInHundredthsOfAmile` is `..._amile` not `..._a_mile`). +func generic_proto_to_map(proto proto.Message) map[string]interface{} { + proto_json, err := protojson.Marshal(proto) + if err != nil { + return nil + } + var unmarshal any + err = json.Unmarshal(proto_json, &unmarshal) + if err != nil { + return nil + } + as_map, ok := unmarshal.(map[string]interface{}) + if !ok { + return nil + } + + camelToSnake := func(input string) string { + re := regexp.MustCompile("([a-z])([A-Z])") + snake := re.ReplaceAllString(input, "${1}_${2}") + return strings.ToLower(snake) + } + + var edit_object func(any) + + edit_object = func(m interface{}) { + switch m_ := m.(type) { + case map[string]interface{}: + rename_keys := make([]string, 0) + for k, v := range m_ { + mm, ok := v.(map[string]interface{}) + if ok { + // Detect values to be flattened + // key: { Value: {} } -> key: Value + if len(mm) == 1 { + for possible_value, maybe_empty_map := range mm { + if mem, ok := maybe_empty_map.(map[string]interface{}); ok { + if len(mem) == 0 { + m_[k] = possible_value + } else { + edit_object(maybe_empty_map) + } + } + } + } else { + edit_object(v) + } + } else if ms, ok := v.(string); ok { + // Maybe timestamp, convert to unix timestamp + if t, err := time.Parse(time.RFC3339Nano, ms); err == nil { + m_[k] = t.Unix() + } + } else { + edit_object(v) + } + rename_keys = append(rename_keys, k) + } + // camel case to snake case + for _, k := range rename_keys { + snake := camelToSnake(k) + if snake == k { + continue + } + m_[snake] = m_[k] + delete(m_, k) + } + case []interface{}: + for _, v := range m_ { + edit_object(v) + } + } + } + + edit_object(as_map) + + return as_map +} + +func DriveStateFromBle(vehicleData *carserver.VehicleData) map[string]interface{} { + return generic_proto_to_map(vehicleData.DriveState) +} +func LocationDataFromBle(vehicleData *carserver.VehicleData) map[string]interface{} { + return generic_proto_to_map(vehicleData.LocationState) +} +func ClosuresStateFromBle(vehicleData *carserver.VehicleData) map[string]interface{} { + return generic_proto_to_map(vehicleData.ClosuresState) +} +func ChargeScheduleDataFromBle(vehicleData *carserver.VehicleData) map[string]interface{} { + return generic_proto_to_map(vehicleData.ChargeScheduleState) +} +func PreconditioningScheduleDataFromBle(vehicleData *carserver.VehicleData) map[string]interface{} { + return generic_proto_to_map(vehicleData.PreconditioningScheduleState) +} +func TirePressureFromBle(vehicleData *carserver.VehicleData) map[string]interface{} { + return generic_proto_to_map(vehicleData.TirePressureState) +} +func MediaFromBle(vehicleData *carserver.VehicleData) map[string]interface{} { + return generic_proto_to_map(vehicleData.MediaState) +} +func MediaDetailFromBle(vehicleData *carserver.VehicleData) map[string]interface{} { + return generic_proto_to_map(vehicleData.MediaDetailState) +} +func SoftwareUpdateFromBle(vehicleData *carserver.VehicleData) map[string]interface{} { + return generic_proto_to_map(vehicleData.SoftwareUpdateState) +} +func ParentalControlsFromBle(vehicleData *carserver.VehicleData) map[string]interface{} { + return generic_proto_to_map(vehicleData.ParentalControlsState) +} diff --git a/internal/tesla/commands/command.go b/internal/tesla/commands/command.go index 4501129..c2c09e2 100644 --- a/internal/tesla/commands/command.go +++ b/internal/tesla/commands/command.go @@ -41,13 +41,13 @@ type Command struct { } var categoriesByName = map[string]vehicle.StateCategory{ - "charge_state": vehicle.StateCategoryCharge, - "climate_state": vehicle.StateCategoryClimate, - // "drive_state": vehicle.StateCategoryDrive, - // "location_data": vehicle.StateCategoryLocation, - // "closures_state": vehicle.StateCategoryClosures, - // "charge_schedule_data": vehicle.StateCategoryChargeSchedule, - // "preconditioning_schedule_data": vehicle.StateCategoryPreconditioningSchedule, + "charge_state": vehicle.StateCategoryCharge, + "climate_state": vehicle.StateCategoryClimate, + "drive_state": vehicle.StateCategoryDrive, + "location_data": vehicle.StateCategoryLocation, + "closures_state": vehicle.StateCategoryClosures, + "charge_schedule_data": vehicle.StateCategoryChargeSchedule, + "preconditioning_schedule_data": vehicle.StateCategoryPreconditioningSchedule, // Missing standard categories // "gui_settings" @@ -56,11 +56,11 @@ var categoriesByName = map[string]vehicle.StateCategory{ // "vehicle_data_combo" // Non-standard categories - // "tire_pressure": vehicle.StateCategoryTirePressure, - // "media": vehicle.StateCategoryMedia, - // "media_detail": vehicle.StateCategoryMediaDetail, - // "software_update": vehicle.StateCategorySoftwareUpdate, - // "parental_controls": vehicle.StateCategoryParentalControls, + "tire_pressure": vehicle.StateCategoryTirePressure, + "media": vehicle.StateCategoryMedia, + "media_detail": vehicle.StateCategoryMediaDetail, + "software_update": vehicle.StateCategorySoftwareUpdate, + "parental_controls": vehicle.StateCategoryParentalControls, } func (command *Command) Domain() DomainType { diff --git a/internal/tesla/commands/commands.go b/internal/tesla/commands/commands.go index 8f68eb5..bd90226 100644 --- a/internal/tesla/commands/commands.go +++ b/internal/tesla/commands/commands.go @@ -83,26 +83,26 @@ func (command *Command) Send(ctx context.Context, car *vehicle.Vehicle) (shouldR converted = models.ChargeStateFromBle(data) case "climate_state": converted = models.ClimateStateFromBle(data) - // case "drive_state": - // converted = models.DriveStateFromBle(data) - // case "location_data": - // converted = models.LocationDataFromBle(data) - // case "closures_state": - // converted = models.ClosuresStateFromBle(data) - // case "charge_schedule_data": - // converted = models.ChargeScheduleDataFromBle(data) - // case "preconditioning_schedule_data": - // converted = models.PreconditioningScheduleDataFromBle(data) - // case "tire_pressure": - // converted = models.TirePressureFromBle(data) - // case "media": - // converted = models.MediaFromBle(data) - // case "media_detail": - // converted = models.MediaDetailFromBle(data) - // case "software_update": - // converted = models.SoftwareUpdateFromBle(data) - // case "parental_controls": - // converted = models.ParentalControlsFromBle(data) + case "drive_state": + converted = models.DriveStateFromBle(data) + case "location_data": + converted = models.LocationDataFromBle(data) + case "closures_state": + converted = models.ClosuresStateFromBle(data) + case "charge_schedule_data": + converted = models.ChargeScheduleDataFromBle(data) + case "preconditioning_schedule_data": + converted = models.PreconditioningScheduleDataFromBle(data) + case "tire_pressure": + converted = models.TirePressureFromBle(data) + case "media": + converted = models.MediaFromBle(data) + case "media_detail": + converted = models.MediaDetailFromBle(data) + case "software_update": + converted = models.SoftwareUpdateFromBle(data) + case "parental_controls": + converted = models.ParentalControlsFromBle(data) case "gui_settings": fallthrough case "vehicle_config": From 87859d97f2c90d3f4b37fa23e668d2eef25fa244 Mon Sep 17 00:00:00 2001 From: Lenart Kos Date: Mon, 27 Jan 2025 20:12:47 +0100 Subject: [PATCH 06/41] Update README to include all supported endpoints for data requests --- README.md | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/README.md b/README.md index 68d311e..0000152 100644 --- a/README.md +++ b/README.md @@ -162,6 +162,20 @@ If you want to receive specific data, you can add the endpoints to the request. This is recommended if you want to receive data frequently, since it will reduce the time it takes to receive the data. +All of the supported endpoints are: +- charge_schedule_data +- charge_state +- climate_state +- closures_state +- drive_state +- location_data +- media_detail +- media +- parental_controls +- preconditioning_schedule_data +- software_update +- tire_pressure + ### Body Controller State The body controller state is fetched from the vehicle and returnes the state of the body controller. The request does not wake up the vehicle. The following information is returned: From 58911199cc8dcc776edb451fa4e473155baa5648 Mon Sep 17 00:00:00 2001 From: Lenart Kos Date: Mon, 27 Jan 2025 20:45:21 +0100 Subject: [PATCH 07/41] Add small timeout to startInfotainmentSession --- internal/ble/control/control.go | 35 ++++++++++++++++++++++++--------- 1 file changed, 26 insertions(+), 9 deletions(-) diff --git a/internal/ble/control/control.go b/internal/ble/control/control.go index 9e3f507..167eb22 100644 --- a/internal/ble/control/control.go +++ b/internal/ble/control/control.go @@ -10,6 +10,7 @@ import ( "time" "github.com/charmbracelet/log" + "github.com/pkg/errors" "github.com/teslamotors/vehicle-command/pkg/connector/ble" "github.com/teslamotors/vehicle-command/pkg/protocol" "github.com/teslamotors/vehicle-command/pkg/protocol/protobuf/universalmessage" @@ -233,16 +234,32 @@ func (bc *BleControl) connectToVehicleAndOperateConnection(firstCommand *command func (bc *BleControl) startInfotainmentSession(ctx context.Context, car *vehicle.Vehicle) error { log.Debug("start Infotainment session...") - // Then we can also connect the infotainment - if err := car.StartSession(ctx, []universalmessage.Domain{ - protocol.DomainVCSEC, - protocol.DomainInfotainment, - }); err != nil { - return fmt.Errorf("failed to perform handshake with vehicle (B): %s", err) + + for { + // FIX: /~https://github.com/teslamotors/vehicle-command/issues/366 + // Timeout since we can't rely on car.StartSession to return (even an error) if + // the car is not ready yet. Maybe it's a bug in the vehicle package. + ctxTry, cancel := context.WithTimeout(context.Background(), 1000*time.Millisecond) + defer cancel() + // Measure time to connect startSession + start := time.Now() + // Then we can also connect the infotainment + if err := car.StartSession(ctxTry, []universalmessage.Domain{ + protocol.DomainVCSEC, + protocol.DomainInfotainment, + }); err != nil { + if errors.Cause(err) == context.DeadlineExceeded && ctx.Err() == nil { + log.Debug("retrying handshake with vehicle") + continue + } + return fmt.Errorf("failed to perform handshake with vehicle (B): %s", err) + } + log.Debug("handshake with vehicle successful", "duration", time.Since(start)) + + log.Info("connection established") + bc.infotainmentSession = true + return nil } - log.Info("connection established") - bc.infotainmentSession = true - return nil } func (bc *BleControl) TryConnectToVehicle(ctx context.Context, firstCommand *commands.Command) (*ble.Connection, *vehicle.Vehicle, bool, error) { From 1133a035a607689cf4db41f6645a1997e10c4e89 Mon Sep 17 00:00:00 2001 From: Lenart Kos Date: Mon, 27 Jan 2025 20:57:08 +0100 Subject: [PATCH 08/41] Fix wrong context in startInfotainmentSession --- internal/ble/control/control.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/internal/ble/control/control.go b/internal/ble/control/control.go index 167eb22..6ff80b2 100644 --- a/internal/ble/control/control.go +++ b/internal/ble/control/control.go @@ -239,7 +239,7 @@ func (bc *BleControl) startInfotainmentSession(ctx context.Context, car *vehicle // FIX: /~https://github.com/teslamotors/vehicle-command/issues/366 // Timeout since we can't rely on car.StartSession to return (even an error) if // the car is not ready yet. Maybe it's a bug in the vehicle package. - ctxTry, cancel := context.WithTimeout(context.Background(), 1000*time.Millisecond) + ctxTry, cancel := context.WithTimeout(ctx, 1000*time.Millisecond) defer cancel() // Measure time to connect startSession start := time.Now() From a9df39b8e4266d11a805859ec1083197a8a3cc2a Mon Sep 17 00:00:00 2001 From: Lenart Kos Date: Mon, 27 Jan 2025 22:37:51 +0100 Subject: [PATCH 09/41] Fix commands not retrying correctly if connection expires --- internal/ble/control/control.go | 68 ++++++++++++++++++------------ internal/ble/control/key.go | 2 +- internal/tesla/commands/command.go | 11 ++--- 3 files changed, 47 insertions(+), 34 deletions(-) diff --git a/internal/ble/control/control.go b/internal/ble/control/control.go index 6ff80b2..a657d8d 100644 --- a/internal/ble/control/control.go +++ b/internal/ble/control/control.go @@ -367,34 +367,39 @@ func (bc *BleControl) operateConnection(car *vehicle.Vehicle, firstCommand *comm defer func() { bc.operatedBeacon = nil }() - handleCommand := func(command *commands.Command) (doReturn bool, retryCommand *commands.Command) { + handleCommand := func(command *commands.Command) *commands.Command { if processIfConnectionStatusCommand(command, command.Vin == firstCommand.Vin) { - return false, nil + return nil } //If new VIN, close connection if command.Vin != firstCommand.Vin { log.Debug("new VIN, so close connection") - return true, command + return command } - cmd, err, ctx := bc.ExecuteCommand(car, command, connectionCtx) + cmd, err := bc.ExecuteCommand(car, command, connectionCtx) - // If the connection context is done, return to reoperate the connection - if connectionCtx.Err() != nil { - return true, cmd - } - // If the context is not done, return to retry the command - if err != nil && ctx.Err() == nil { - return true, cmd + if err != nil { + if command.TotalRetries >= 3 { + log.Error("failed to execute command after 3 retries", "command", command.Command, "body", command.Body, "error", err.Error()) + return nil + } + if cmd == nil { + log.Error("failed to execute command", "command", command.Command, "body", command.Body, "error", err.Error()) + return nil + } else { + log.Debug("failed to execute command", "command", command.Command, "body", command.Body, "total retires", command.TotalRetries, "error", err.Error()) + return cmd + } } // Successful or api context done so no retry - return false, nil + return nil } - doReturn, retryCommand := handleCommand(firstCommand) - if doReturn { + retryCommand := handleCommand(firstCommand) + if retryCommand != nil { return retryCommand } @@ -408,8 +413,8 @@ func (bc *BleControl) operateConnection(car *vehicle.Vehicle, firstCommand *comm return nil } - doReturn, retryCommand := handleCommand(&command) - if doReturn { + retryCommand := handleCommand(&command) + if retryCommand != nil { return retryCommand } case command, ok := <-bc.commandStack: @@ -417,16 +422,17 @@ func (bc *BleControl) operateConnection(car *vehicle.Vehicle, firstCommand *comm return nil } - doReturn, retryCommand := handleCommand(&command) - if doReturn { + retryCommand := handleCommand(&command) + if retryCommand != nil { return retryCommand } } } } -func (bc *BleControl) ExecuteCommand(car *vehicle.Vehicle, command *commands.Command, connectionCtx context.Context) (retryCommand *commands.Command, retErr error, ctx context.Context) { +func (bc *BleControl) ExecuteCommand(car *vehicle.Vehicle, command *commands.Command, connectionCtx context.Context) (retryCommand *commands.Command, retErr error) { log.Debug("sending", "command", command.Command, "body", command.Body) + var ctx context.Context if command.Response != nil && command.Response.Ctx != nil { ctx = command.Response.Ctx } else { @@ -458,7 +464,7 @@ func (bc *BleControl) ExecuteCommand(car *vehicle.Vehicle, command *commands.Com // If the context is already done, return immediately if ctx.Err() != nil { - return nil, ctx.Err(), ctx + return nil, ctx.Err() } // Wrap ctx with connectionCtx @@ -472,17 +478,23 @@ func (bc *BleControl) ExecuteCommand(car *vehicle.Vehicle, command *commands.Com } }() - for i := 0; i < retryCount; i++ { - if i > 0 { + dontSkipWait := false + for ; command.TotalRetries < retryCount; command.TotalRetries++ { + if dontSkipWait { log.Warn(lastErr) log.Info(fmt.Sprintf("retrying in %d seconds", sleep/time.Second)) select { case <-time.After(sleep): case <-ctx.Done(): - return nil, ctx.Err(), ctx + if connectionCtx.Err() != nil { + log.Debug("operated connection expired") + return command, errors.Wrap(ctx.Err(), "operated connection expired") + } + return nil, ctx.Err() } sleep *= 2 } + dontSkipWait = true if !bc.infotainmentSession && command.Domain() == commands.Domain.Infotainment { if err := car.Wakeup(ctx); err != nil { @@ -502,18 +514,18 @@ func (bc *BleControl) ExecuteCommand(car *vehicle.Vehicle, command *commands.Com if err == nil { //Successful log.Info("successfully executed", "command", command.Command, "body", command.Body) - return nil, nil, ctx + return nil, nil } else if !retry { - return nil, nil, ctx + return nil, nil } else { //closed pipe if strings.Contains(err.Error(), "closed pipe") { //connection lost, returning the command so it can be executed again - return command, err, ctx + return command, err } lastErr = err } } - log.Error("canceled", "command", command.Command, "body", command.Body, "err", lastErr) - return nil, lastErr, ctx + log.Warn("max retries reached", "command", command.Command, "body", command.Body, "err", lastErr) + return nil, lastErr } diff --git a/internal/ble/control/key.go b/internal/ble/control/key.go index aac0222..8fd3e72 100644 --- a/internal/ble/control/key.go +++ b/internal/ble/control/key.go @@ -104,7 +104,7 @@ func SendKeysToVehicle(vin string) error { defer car.Disconnect() defer log.Debug("disconnect vehicle (A)") - _, err, _ := tempBleControl.ExecuteCommand(car, cmd, context.Background()) + _, err := tempBleControl.ExecuteCommand(car, cmd, context.Background()) if err != nil { return err } diff --git a/internal/tesla/commands/command.go b/internal/tesla/commands/command.go index c2c09e2..cf3d773 100644 --- a/internal/tesla/commands/command.go +++ b/internal/tesla/commands/command.go @@ -33,11 +33,12 @@ var CommandSource = struct { } type Command struct { - Command string - Source CommandSourceType - Vin string - Body map[string]interface{} - Response *models.ApiResponse + Command string + Source CommandSourceType + Vin string + Body map[string]interface{} + TotalRetries int + Response *models.ApiResponse } var categoriesByName = map[string]vehicle.StateCategory{ From e05c8ed8e01a4b8f92667266db1f6e458802c0bf Mon Sep 17 00:00:00 2001 From: Lenart Kos Date: Tue, 28 Jan 2025 09:33:30 +0100 Subject: [PATCH 10/41] Update vehicle-command dependency to support BLE scanning --- go.mod | 6 +----- go.sum | 4 ++++ 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/go.mod b/go.mod index 52ad090..28cb4f8 100644 --- a/go.mod +++ b/go.mod @@ -5,7 +5,7 @@ go 1.23.3 require ( github.com/charmbracelet/log v0.4.0 github.com/gorilla/mux v1.8.1 - github.com/teslamotors/vehicle-command v0.2.1 + github.com/teslamotors/vehicle-command v0.3.3-0.20250128004836-ebad42aaa852 ) require ( @@ -33,7 +33,3 @@ require ( golang.org/x/sys v0.24.0 // indirect google.golang.org/protobuf v1.34.2 // indirect ) - -replace github.com/teslamotors/vehicle-command => github.com/wimaha/vehicle-command v0.0.4 - -replace github.com/go-ble/ble => github.com/wimaha/ble_BleConnectFix v0.0.0-20240822192426-3f74826c1268 diff --git a/go.sum b/go.sum index 362bd35..55f48b9 100644 --- a/go.sum +++ b/go.sum @@ -16,6 +16,8 @@ github.com/cronokirby/saferith v0.33.0/go.mod h1:QKJhjoqUtBsXCAVEjw38mFqoi7DebT7 github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/go-ble/ble v0.0.0-20240122180141-8c5522f54333 h1:bQK6D51cNzMSTyAf0HtM30V2IbljHTDam7jru9JNlJA= +github.com/go-ble/ble v0.0.0-20240122180141-8c5522f54333/go.mod h1:fFJl/jD/uyILGBeD5iQ8tYHrPlJafyqCJzAyTHNJ1Uk= github.com/go-logfmt/logfmt v0.6.0 h1:wGYYu3uicYdqXVgoYbvnkrPVXkuLM1p1ifugDMEdRi4= github.com/go-logfmt/logfmt v0.6.0/go.mod h1:WYhtIu8zTZfxdn5+rREduYbwxfcBr/Vr6KEVveWlfTs= github.com/golang-jwt/jwt/v5 v5.2.1 h1:OuVbFODueb089Lh128TAcimifWaLhJwVflnrgM17wHk= @@ -67,6 +69,8 @@ github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81P github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +github.com/teslamotors/vehicle-command v0.3.3-0.20250128004836-ebad42aaa852 h1:TrYeM7qS2mB80Zl8T+xqlzxNG6dEmpRaK6oPrDGQBIo= +github.com/teslamotors/vehicle-command v0.3.3-0.20250128004836-ebad42aaa852/go.mod h1:ZVR0KE8v3IrQUJAuBrxKkRjPZOVI0oxEqBj8x1eFpDQ= github.com/urfave/cli v1.22.2/go.mod h1:Gos4lmkARVdJ6EkW0WaNv/tZAAMe9V7XWyB60NtXRu0= github.com/wimaha/ble_BleConnectFix v0.0.0-20240822192426-3f74826c1268 h1:36sDJ2qts2oT/Fy/Wi6MR15C+LHl2+ZUzLp6UaqhS9c= github.com/wimaha/ble_BleConnectFix v0.0.0-20240822192426-3f74826c1268/go.mod h1:fFJl/jD/uyILGBeD5iQ8tYHrPlJafyqCJzAyTHNJ1Uk= From d669d155901cb0e296007147908f9ce58803fdc5 Mon Sep 17 00:00:00 2001 From: Lenart Kos Date: Tue, 28 Jan 2025 09:35:24 +0100 Subject: [PATCH 11/41] Fix linter errors --- internal/tesla/commands/command.go | 1 - internal/tesla/commands/fleetVehicleCommands.go | 2 +- 2 files changed, 1 insertion(+), 2 deletions(-) diff --git a/internal/tesla/commands/command.go b/internal/tesla/commands/command.go index cf3d773..4eb77b8 100644 --- a/internal/tesla/commands/command.go +++ b/internal/tesla/commands/command.go @@ -73,7 +73,6 @@ func (command *Command) Domain() DomainType { default: return Domain.Infotainment } - } func GetCategory(nameStr string) (vehicle.StateCategory, error) { diff --git a/internal/tesla/commands/fleetVehicleCommands.go b/internal/tesla/commands/fleetVehicleCommands.go index 0890170..9294095 100644 --- a/internal/tesla/commands/fleetVehicleCommands.go +++ b/internal/tesla/commands/fleetVehicleCommands.go @@ -828,7 +828,7 @@ var fleetVehicleCommands = map[string]fleetVehicleCommandHandler{ return fmt.Errorf("invalid 'lon' value: %f", lon) } - // Offical API requires token, but it's not needed here + // Official API requires token, but it's not needed here // so we just validate it for completeness and to avoid errors if _, ok := args.validateString("token", false); ok != nil { return err From 0c365d9aeb2ac761721939e8e9c364db8e7a2411 Mon Sep 17 00:00:00 2001 From: Lenart Kos Date: Tue, 28 Jan 2025 11:07:52 +0100 Subject: [PATCH 12/41] Fix `session_info` and `add-key-request` not working --- internal/api/handlers/tesla.go | 1 + internal/ble/control/key.go | 1 + 2 files changed, 2 insertions(+) diff --git a/internal/api/handlers/tesla.go b/internal/api/handlers/tesla.go index d9cf040..1d31a83 100644 --- a/internal/api/handlers/tesla.go +++ b/internal/api/handlers/tesla.go @@ -171,6 +171,7 @@ func ProxyCommand(w http.ResponseWriter, r *http.Request) { switch command { case "connection_status": case "body_controller_state": + case "session_info": default: writeResponseWithStatus(w, &models.Response{Vin: vin, Command: command, Result: false, Reason: "Unrecognized command: " + command}) return diff --git a/internal/ble/control/key.go b/internal/ble/control/key.go index 8fd3e72..c8d6f3b 100644 --- a/internal/ble/control/key.go +++ b/internal/ble/control/key.go @@ -94,6 +94,7 @@ func SendKeysToVehicle(vin string) error { defer cancel() cmd := &commands.Command{ Command: "add-key-request", + Source: commands.CommandSource.TeslaBleHttpProxy, Vin: vin, } conn, car, _, err := tempBleControl.TryConnectToVehicle(ctx, cmd) From 8f6fa1e6d25c51a00f32b962d06a5b6911f34d26 Mon Sep 17 00:00:00 2001 From: Lenart Kos Date: Wed, 29 Jan 2025 10:56:08 +0100 Subject: [PATCH 13/41] Change `wake_up` to be more like fleet command Require POST parameter and have a wait flag --- internal/api/handlers/tesla.go | 18 +++++++++++++++++- internal/api/routes/routes.go | 2 +- internal/tesla/commands/command.go | 2 -- internal/tesla/commands/commands.go | 6 +++--- 4 files changed, 21 insertions(+), 7 deletions(-) diff --git a/internal/api/handlers/tesla.go b/internal/api/handlers/tesla.go index 1d31a83..61e999f 100644 --- a/internal/api/handlers/tesla.go +++ b/internal/api/handlers/tesla.go @@ -127,10 +127,26 @@ func VehicleEndpoint(w http.ResponseWriter, r *http.Request) { var body map[string]interface{} = nil src := commands.CommandSource.FleetVehicleEndpoint + wait := true + + checkMethod := func(expected string) bool { + if r.Method != expected { + writeResponseWithStatus(w, &models.Response{Vin: vin, Command: command, Result: false, Reason: "Invalid method: " + r.Method}) + return false + } + return true + } switch command { case "wake_up": + if !checkMethod("POST") { + return + } + wait = r.URL.Query().Get("wait") == "true" case "vehicle_data": + if !checkMethod("GET") { + return + } var endpoints []string endpointsString := r.URL.Query().Get("endpoints") if endpointsString != "" { @@ -158,7 +174,7 @@ func VehicleEndpoint(w http.ResponseWriter, r *http.Request) { } log.Info("received", "command", command, "body", body) - resp := processCommand(w, r, vin, command, src, body, true) + resp := processCommand(w, r, vin, command, src, body, wait) writeResponseWithStatus(w, &resp) } diff --git a/internal/api/routes/routes.go b/internal/api/routes/routes.go index dcb173c..8272a72 100644 --- a/internal/api/routes/routes.go +++ b/internal/api/routes/routes.go @@ -14,7 +14,7 @@ func SetupRoutes(static embed.FS, html embed.FS) *mux.Router { // Define the endpoints ///api/1/vehicles/{vehicle_tag}/command/set_charging_amps router.HandleFunc("/api/1/vehicles/{vin}/command/{command}", handlers.VehicleCommand).Methods("POST") - router.HandleFunc("/api/1/vehicles/{vin}/{command}", handlers.VehicleEndpoint).Methods("GET") + router.HandleFunc("/api/1/vehicles/{vin}/{command}", handlers.VehicleEndpoint).Methods("GET", "POST") router.HandleFunc("/api/proxy/1/vehicles/{vin}/{command}", handlers.ProxyCommand).Methods("GET") router.HandleFunc("/dashboard", handlers.ShowDashboard(html)).Methods("GET") router.HandleFunc("/gen_keys", handlers.GenKeys).Methods("GET") diff --git a/internal/tesla/commands/command.go b/internal/tesla/commands/command.go index 4eb77b8..6787605 100644 --- a/internal/tesla/commands/command.go +++ b/internal/tesla/commands/command.go @@ -68,8 +68,6 @@ func (command *Command) Domain() DomainType { switch command.Command { case "body_controller_state": fallthrough - case "wake_up": - return Domain.VCSEC default: return Domain.Infotainment } diff --git a/internal/tesla/commands/commands.go b/internal/tesla/commands/commands.go index bd90226..07e18bd 100644 --- a/internal/tesla/commands/commands.go +++ b/internal/tesla/commands/commands.go @@ -128,9 +128,9 @@ func (command *Command) Send(ctx context.Context, car *vehicle.Vehicle) (shouldR } command.Response.Response = responseJson case "wake_up": - if err := car.Wakeup(ctx); err != nil { - return true, fmt.Errorf("failed to wake up vehicle: %s", err) - } + // car.WakeUp and waiting for the car was already handled by the operated + // connection, because this is marked as an infotainment domain command + // Nothing to do here :^) default: return false, fmt.Errorf("unrecognized vehicle endpoint command: %s", command.Command) } From 9b7bdac32f5d842720e25ddc2ab7f797a329ca19 Mon Sep 17 00:00:00 2001 From: Lenart Kos Date: Wed, 29 Jan 2025 12:16:32 +0100 Subject: [PATCH 14/41] Add command line parsing --- config/config.go | 75 ++++++++++++++++++++++++++---------------------- go.mod | 1 + go.sum | 2 ++ main.go | 2 ++ 4 files changed, 45 insertions(+), 35 deletions(-) diff --git a/config/config.go b/config/config.go index 84346cc..9a47806 100644 --- a/config/config.go +++ b/config/config.go @@ -1,9 +1,11 @@ package config import ( + "fmt" + "net/url" "os" - "strconv" + "github.com/akamensky/argparse" "github.com/charmbracelet/log" ) @@ -20,46 +22,49 @@ type Config struct { var AppConfig *Config func LoadConfig() *Config { - envLogLevel := os.Getenv("logLevel") - if envLogLevel == "debug" { - log.SetLevel(log.DebugLevel) - log.Debug("LogLevel set to debug") - } - if envLogLevel == "" { - envLogLevel = "info" - } - - addr := os.Getenv("httpListenAddress") - if addr == "" { - addr = ":8080" - } - log.Info("TeslaBleHttpProxy", "httpListenAddress", addr) + parser := argparse.NewParser("TeslaBleHttpProxy", "Proxy for Tesla BLE commands over HTTP") + logLevel := parser.String("l", "logLevel", &argparse.Options{Help: "Log level (DEBUG, INFO, WARN, ERROR, FATAL)", Default: "INFO", Validate: func(args []string) error { + if _, err := log.ParseLevel(args[0]); err != nil { + return err + } + return nil + }}) + httpListenAddress := parser.String("b", "httpListenAddress", &argparse.Options{Help: "HTTP bind address", Default: ":8080", Validate: func(args []string) error { + // Check if the proxy host is a valid URL + url, err := url.Parse(fmt.Sprintf("//%s", args[0])) + if err != nil { + return fmt.Errorf("invalid bind address (%s)", err) + } + if url.Path != "" { + return fmt.Errorf("bind address must not contain a path or scheme") + } + return nil + }}) + scanTimeout := parser.Int("s", "scanTimeout", &argparse.Options{Help: "Time in seconds to scan for BLE beacons during device scan (0 = max)", Default: 1}) + cacheMaxAge := parser.Int("c", "cacheMaxAge", &argparse.Options{Help: "Time in seconds for Cache-Control header (0 = no cache)", Default: 5}) - scanTimeout := os.Getenv("scanTimeout") - if scanTimeout == "" { - scanTimeout = "1" // default value - } - scanTimeoutInt, err := strconv.Atoi(scanTimeout) - if err != nil || scanTimeoutInt < 0 { - log.Error("Invalid scanTimeout value, using default (1)", "error", err) - scanTimeoutInt = 1 + // Inject environment variables as command line arguments + args := os.Args + for _, arg := range parser.GetArgs() { + if arg.GetPositional() || arg.GetLname() == "help" { + continue + } + osArg := os.Getenv(arg.GetLname()) + if osArg != "" { + args = append(args, fmt.Sprintf("--%s=%s", arg.GetLname(), osArg)) + } } - cacheMaxAge := os.Getenv("cacheMaxAge") - if cacheMaxAge == "" { - cacheMaxAge = "0" // default value - } - cacheMaxAgeInt, err := strconv.Atoi(cacheMaxAge) - if err != nil || cacheMaxAgeInt < 0 { - log.Error("Invalid cacheMaxAge value, using default (0)", "error", err) - cacheMaxAgeInt = 0 + err := parser.Parse(args) + if err != nil { + log.Fatal("Failed to parse arguments", "error", err) } return &Config{ - LogLevel: envLogLevel, - HttpListenAddress: addr, - ScanTimeout: scanTimeoutInt, - CacheMaxAge: cacheMaxAgeInt, + LogLevel: *logLevel, + HttpListenAddress: *httpListenAddress, + ScanTimeout: *scanTimeout, + CacheMaxAge: *cacheMaxAge, } } diff --git a/go.mod b/go.mod index 28cb4f8..69dafa3 100644 --- a/go.mod +++ b/go.mod @@ -10,6 +10,7 @@ require ( require ( github.com/JuulLabs-OSS/cbgo v0.0.2 // indirect + github.com/akamensky/argparse v1.4.0 // indirect github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect github.com/charmbracelet/lipgloss v0.12.1 // indirect github.com/charmbracelet/x/ansi v0.1.4 // indirect diff --git a/go.sum b/go.sum index 55f48b9..cc4ba7f 100644 --- a/go.sum +++ b/go.sum @@ -2,6 +2,8 @@ github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03 github.com/JuulLabs-OSS/cbgo v0.0.1/go.mod h1:L4YtGP+gnyD84w7+jN66ncspFRfOYB5aj9QSXaFHmBA= github.com/JuulLabs-OSS/cbgo v0.0.2 h1:gCDyT0+EPuI8GOFyvAksFcVD2vF4CXBAVwT6uVnD9oo= github.com/JuulLabs-OSS/cbgo v0.0.2/go.mod h1:L4YtGP+gnyD84w7+jN66ncspFRfOYB5aj9QSXaFHmBA= +github.com/akamensky/argparse v1.4.0 h1:YGzvsTqCvbEZhL8zZu2AiA5nq805NZh75JNj4ajn1xc= +github.com/akamensky/argparse v1.4.0/go.mod h1:S5kwC7IuDcEr5VeXtGPRVZ5o/FdhcMlQz4IZQuw64xA= github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k= github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8= github.com/charmbracelet/lipgloss v0.12.1 h1:/gmzszl+pedQpjCOH+wFkZr/N90Snz40J/NR7A0zQcs= diff --git a/main.go b/main.go index 7da1a3c..a6db436 100644 --- a/main.go +++ b/main.go @@ -22,6 +22,8 @@ func main() { log.Infof("TeslaBleHttpProxy %s is loading ...", Version) config.InitConfig() + level, _ := log.ParseLevel(config.AppConfig.LogLevel) + log.SetLevel(level) control.SetupBleControl() From 748647e47c41fa76bd7469c514a327d2b5fb88ca Mon Sep 17 00:00:00 2001 From: Lenart Kos Date: Wed, 29 Jan 2025 12:25:10 +0100 Subject: [PATCH 15/41] Allow keys directory to be specified --- config/config.go | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/config/config.go b/config/config.go index 9a47806..aeab88e 100644 --- a/config/config.go +++ b/config/config.go @@ -42,6 +42,16 @@ func LoadConfig() *Config { }}) scanTimeout := parser.Int("s", "scanTimeout", &argparse.Options{Help: "Time in seconds to scan for BLE beacons during device scan (0 = max)", Default: 1}) cacheMaxAge := parser.Int("c", "cacheMaxAge", &argparse.Options{Help: "Time in seconds for Cache-Control header (0 = no cache)", Default: 5}) + keys := parser.String("k", "keys", &argparse.Options{Help: "Path to public and private keys", Default: "key", Validate: func(args []string) error { + f, err := os.Stat(args[0]) + if err != nil { + return fmt.Errorf("failed to find keys directory (%s)", err) + } + if !f.IsDir() { + return fmt.Errorf("keys is not a directory") + } + return nil + }}) // Inject environment variables as command line arguments args := os.Args @@ -60,6 +70,9 @@ func LoadConfig() *Config { log.Fatal("Failed to parse arguments", "error", err) } + PublicKeyFile = fmt.Sprintf("%s/public.pem", *keys) + PrivateKeyFile = fmt.Sprintf("%s/private.pem", *keys) + return &Config{ LogLevel: *logLevel, HttpListenAddress: *httpListenAddress, From fe13af735c230604e7177f7551058db05d32e46a Mon Sep 17 00:00:00 2001 From: Lenart Kos Date: Wed, 29 Jan 2025 12:32:26 +0100 Subject: [PATCH 16/41] Update README and environment variables documentation with command line options and usage details --- README.md | 20 +++++++++++++++++++- docs/environment_variables.md | 11 ++++++++--- 2 files changed, 27 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index 0000152..02580f3 100644 --- a/README.md +++ b/README.md @@ -54,7 +54,25 @@ Download the code and save it in a folder named 'TeslaBleHttpProxy'. From there, ``` go build . -./TeslaBleHttpProxy +./TeslaBleHttpProxy -h +usage: TeslaBleHttpProxy [-h|--help] [-l|--logLevel ""] + [-b|--httpListenAddress ""] [-s|--scanTimeout + ] [-c|--cacheMaxAge ] [-k|--keys + ""] + + Proxy for Tesla BLE commands over HTTP + +Arguments: + + -h --help Print help information + -l --logLevel Log level (DEBUG, INFO, WARN, ERROR, FATAL). + Default: INFO + -b --httpListenAddress HTTP bind address. Default: :8080 + -s --scanTimeout Time in seconds to scan for BLE beacons during + device scan (0 = max). Default: 1 + -c --cacheMaxAge Time in seconds for Cache-Control header (0 = no + cache). Default: 5 + -k --keys Path to public and private keys. Default: key ``` Please remember to create an empty folder called `key` where the keys can be stored later. diff --git a/docs/environment_variables.md b/docs/environment_variables.md index e72bb2b..916409f 100644 --- a/docs/environment_variables.md +++ b/docs/environment_variables.md @@ -4,11 +4,11 @@ You can optionally set environment variables to override the default behavior. ## logLevel -This is the log level. Options: debug (Default: info) +This is the log level. Options: debug (Default: INFO) ## cacheMaxAge -This is the number of seconds to cache the BLE responses for vehicle data and body controller state. If set to 0, the cache is disabled. (Default: 0) +This is the value that will be set in Cache-Control header for vehicle data and body controller state responses. If set to 0, the cache is disabled. (Default: 5) ## httpListenAddress @@ -33,5 +33,10 @@ This will set the log level to debug, the cache max age to 30 seconds, and the H You can also set the environment variables in the command line when starting the program. Example: ``` -logLevel=debug cacheMaxAge=30 httpListenAddress=:5687 ./TeslaBleHttpProxy +./TeslaBleHttpProxy --logLevel=debug --cacheMaxAge=30 --httpListenAddress=:5687 ``` + +## Caution + +> [!WARNING] +> If you set both environment variables and command line options for the same setting, you will see the error `[command] can only be present once` From 78d8d0dca297a2d635f8b8ce8733a03cc57bf779 Mon Sep 17 00:00:00 2001 From: Lenart Kos Date: Wed, 29 Jan 2025 19:55:12 +0100 Subject: [PATCH 17/41] Fix add-key-request failing to start because it was marked as infotainment command --- internal/tesla/commands/command.go | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/internal/tesla/commands/command.go b/internal/tesla/commands/command.go index 6787605..bcd44ea 100644 --- a/internal/tesla/commands/command.go +++ b/internal/tesla/commands/command.go @@ -66,8 +66,12 @@ var categoriesByName = map[string]vehicle.StateCategory{ func (command *Command) Domain() DomainType { switch command.Command { - case "body_controller_state": + case "session_info": + fallthrough + case "add-key-request": fallthrough + case "body_controller_state": + return Domain.VCSEC default: return Domain.Infotainment } From 39f530730fa3c2f00b800a955e1f4fe075cef21a Mon Sep 17 00:00:00 2001 From: Lenart Kos Date: Thu, 30 Jan 2025 13:14:29 +0100 Subject: [PATCH 18/41] Redirect root path to /dashboard --- internal/api/routes/routes.go | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/internal/api/routes/routes.go b/internal/api/routes/routes.go index 8272a72..ed00b5a 100644 --- a/internal/api/routes/routes.go +++ b/internal/api/routes/routes.go @@ -22,5 +22,10 @@ func SetupRoutes(static embed.FS, html embed.FS) *mux.Router { router.HandleFunc("/send_key", handlers.SendKey).Methods("POST") router.PathPrefix("/static/").Handler(http.FileServer(http.FS(static))) + // Redirect / to /dashboard + router.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { + http.Redirect(w, r, "/dashboard", http.StatusSeeOther) + }) + return router } From 43c7863c6ddc873fff85c7d10d2e07586099932c Mon Sep 17 00:00:00 2001 From: Lenart Kos Date: Thu, 30 Jan 2025 14:04:35 +0100 Subject: [PATCH 19/41] Show route in 404 --- internal/api/routes/routes.go | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/internal/api/routes/routes.go b/internal/api/routes/routes.go index ed00b5a..57300f2 100644 --- a/internal/api/routes/routes.go +++ b/internal/api/routes/routes.go @@ -27,5 +27,11 @@ func SetupRoutes(static embed.FS, html embed.FS) *mux.Router { http.Redirect(w, r, "/dashboard", http.StatusSeeOther) }) + // 404 show route + router.NotFoundHandler = http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + path := r.URL.Path + http.Error(w, "404 page not found: "+path, http.StatusNotFound) + }) + return router } From 69d540a9a6ef6d0b1237c39ffa6eb9465bc5db67 Mon Sep 17 00:00:00 2001 From: Lenart Kos Date: Thu, 30 Jan 2025 15:53:49 +0100 Subject: [PATCH 20/41] Add ProxyBaseURL configuration and update routes to use it --- config/config.go | 7 ++++-- html/dashboard.html | 6 ++--- html/layout.html | 2 +- internal/api/handlers/html.go | 11 ++++++--- internal/api/routes/routes.go | 43 ++++++++++++++++++++++++++--------- 5 files changed, 49 insertions(+), 20 deletions(-) diff --git a/config/config.go b/config/config.go index aeab88e..0279bc1 100644 --- a/config/config.go +++ b/config/config.go @@ -15,8 +15,9 @@ var PrivateKeyFile = "key/private.pem" type Config struct { LogLevel string HttpListenAddress string - ScanTimeout int // Seconds to scan for BLE beacons during device scan (0 = max) - CacheMaxAge int // Seconds to cache BLE responses + ScanTimeout int // Seconds to scan for BLE beacons during device scan (0 = max) + CacheMaxAge int // Seconds to cache BLE responses + ProxyBaseURL string // Base URL for proxying BLE commands (Useful if the proxy is behind a reverse proxy) } var AppConfig *Config @@ -52,6 +53,7 @@ func LoadConfig() *Config { } return nil }}) + proxyBaseUrl := parser.String("p", "proxyBaseUrl", &argparse.Options{Help: "Base URL for proxying BLE commands (Useful if the proxy is behind a reverse proxy)", Default: ""}) // Inject environment variables as command line arguments args := os.Args @@ -78,6 +80,7 @@ func LoadConfig() *Config { HttpListenAddress: *httpListenAddress, ScanTimeout: *scanTimeout, CacheMaxAge: *cacheMaxAge, + ProxyBaseURL: *proxyBaseUrl, } } diff --git a/html/dashboard.html b/html/dashboard.html index b4bc337..73a1993 100644 --- a/html/dashboard.html +++ b/html/dashboard.html @@ -27,14 +27,14 @@

TeslaBleHttpProxy

{{ if eq .ShouldGenKeys true }}
-
+
{{ else }}

Important: Only use this function, if you know what you are doing. This can't be undone!
-
+
@@ -55,7 +55,7 @@

Setup Vehicle

The vehicle has to be awake! So you have to manually wake the vehicle before you send the key to the vehicle.
-
+
  • diff --git a/html/layout.html b/html/layout.html index df6bc43..7088f32 100644 --- a/html/layout.html +++ b/html/layout.html @@ -3,7 +3,7 @@ - + TeslaBleHttpProxy diff --git a/internal/api/handlers/html.go b/internal/api/handlers/html.go index 7b8c918..8861456 100644 --- a/internal/api/handlers/html.go +++ b/internal/api/handlers/html.go @@ -19,6 +19,7 @@ type DashboardParams struct { PublicKey string ShouldGenKeys bool Messages []models.Message + BaseUrl string } func ShowDashboard(html fs.FS) http.HandlerFunc { @@ -43,6 +44,7 @@ func ShowDashboard(html fs.FS) http.HandlerFunc { PublicKey: publicKey, ShouldGenKeys: shouldGenKeys, Messages: messages, + BaseUrl: config.AppConfig.ProxyBaseURL, } if err := Dashboard(w, p, "", html); err != nil { log.Error("error showing dashboard", "error", err) @@ -67,7 +69,8 @@ func GenKeys(w http.ResponseWriter, r *http.Request) { Type: models.Error, }) } - http.Redirect(w, r, "/dashboard", http.StatusSeeOther) + base := config.AppConfig.ProxyBaseURL + http.Redirect(w, r, base+"/dashboard", http.StatusSeeOther) } func RemoveKeys(w http.ResponseWriter, r *http.Request) { @@ -96,12 +99,14 @@ func RemoveKeys(w http.ResponseWriter, r *http.Request) { } control.CloseBleControl() - http.Redirect(w, r, "/dashboard", http.StatusSeeOther) + base := config.AppConfig.ProxyBaseURL + http.Redirect(w, r, base+"/dashboard", http.StatusSeeOther) } func SendKey(w http.ResponseWriter, r *http.Request) { defer func() { - http.Redirect(w, r, "/dashboard", http.StatusSeeOther) + base := config.AppConfig.ProxyBaseURL + http.Redirect(w, r, base+"/dashboard", http.StatusSeeOther) }() if r.Method == http.MethodPost { diff --git a/internal/api/routes/routes.go b/internal/api/routes/routes.go index 57300f2..2c721c7 100644 --- a/internal/api/routes/routes.go +++ b/internal/api/routes/routes.go @@ -4,31 +4,52 @@ import ( "embed" "net/http" + "github.com/charmbracelet/log" "github.com/gorilla/mux" + "github.com/wimaha/TeslaBleHttpProxy/config" "github.com/wimaha/TeslaBleHttpProxy/internal/api/handlers" ) func SetupRoutes(static embed.FS, html embed.FS) *mux.Router { router := mux.NewRouter() + base := config.AppConfig.ProxyBaseURL + // Define the endpoints - ///api/1/vehicles/{vehicle_tag}/command/set_charging_amps - router.HandleFunc("/api/1/vehicles/{vin}/command/{command}", handlers.VehicleCommand).Methods("POST") - router.HandleFunc("/api/1/vehicles/{vin}/{command}", handlers.VehicleEndpoint).Methods("GET", "POST") - router.HandleFunc("/api/proxy/1/vehicles/{vin}/{command}", handlers.ProxyCommand).Methods("GET") - router.HandleFunc("/dashboard", handlers.ShowDashboard(html)).Methods("GET") - router.HandleFunc("/gen_keys", handlers.GenKeys).Methods("GET") - router.HandleFunc("/remove_keys", handlers.RemoveKeys).Methods("GET") - router.HandleFunc("/send_key", handlers.SendKey).Methods("POST") - router.PathPrefix("/static/").Handler(http.FileServer(http.FS(static))) + router.HandleFunc(base+"/api/1/vehicles/{vin}/command/{command}", handlers.VehicleCommand).Methods("POST") + router.HandleFunc(base+"/api/1/vehicles/{vin}/{command}", handlers.VehicleEndpoint).Methods("GET", "POST") + router.HandleFunc(base+"/api/proxy/1/vehicles/{vin}/{command}", handlers.ProxyCommand).Methods("GET") + router.HandleFunc(base+"/dashboard", handlers.ShowDashboard(html)).Methods("GET") + router.HandleFunc(base+"/gen_keys", handlers.GenKeys).Methods("GET") + router.HandleFunc(base+"/remove_keys", handlers.RemoveKeys).Methods("GET") + router.HandleFunc(base+"/send_key", handlers.SendKey).Methods("POST") + + // Static files + staticHandler := http.FileServer(http.FS(static)) + if base != "" { + staticHandler = http.StripPrefix(base, staticHandler) + } + router.PathPrefix(base + "/static/").Handler(staticHandler) // Redirect / to /dashboard - router.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { - http.Redirect(w, r, "/dashboard", http.StatusSeeOther) + indexPath := base + if len(indexPath) == 0 { + indexPath = "/" + } + router.HandleFunc(indexPath, func(w http.ResponseWriter, r *http.Request) { + http.Redirect(w, r, base+"/dashboard", http.StatusSeeOther) + }) + + router.MethodNotAllowedHandler = http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + log.Debug("405 method not allowed", "method", r.Method, "path", r.URL.Path) + method := r.Method + path := r.URL.Path + http.Error(w, "405 method not allowed: "+method+" "+path, http.StatusMethodNotAllowed) }) // 404 show route router.NotFoundHandler = http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + log.Debug("404 page not found", "path", r.URL.Path) path := r.URL.Path http.Error(w, "404 page not found: "+path, http.StatusNotFound) }) From c8284620b8c83033a11a478fe89a4c7634dc5f23 Mon Sep 17 00:00:00 2001 From: Lenart Kos Date: Thu, 30 Jan 2025 17:10:06 +0100 Subject: [PATCH 21/41] Refactor configuration to separate Dashboard and API base URLs, updating routes and handlers accordingly --- config/config.go | 10 ++++++---- internal/api/handlers/html.go | 8 ++++---- internal/api/routes/routes.go | 32 +++++++++++++++++--------------- 3 files changed, 27 insertions(+), 23 deletions(-) diff --git a/config/config.go b/config/config.go index 0279bc1..9fe8c13 100644 --- a/config/config.go +++ b/config/config.go @@ -17,7 +17,8 @@ type Config struct { HttpListenAddress string ScanTimeout int // Seconds to scan for BLE beacons during device scan (0 = max) CacheMaxAge int // Seconds to cache BLE responses - ProxyBaseURL string // Base URL for proxying BLE commands (Useful if the proxy is behind a reverse proxy) + DashboardBaseURL string // Base URL for proxying dashboard (Useful if the proxy is behind a reverse proxy) + ApiBaseUrl string // Base URL for proxying BLE commands (Useful if the proxy is behind a reverse proxy) } var AppConfig *Config @@ -53,8 +54,8 @@ func LoadConfig() *Config { } return nil }}) - proxyBaseUrl := parser.String("p", "proxyBaseUrl", &argparse.Options{Help: "Base URL for proxying BLE commands (Useful if the proxy is behind a reverse proxy)", Default: ""}) - + dashboardBaseUrl := parser.String("d", "dashboardBaseUrl", &argparse.Options{Help: "Base URL for dashboard (Useful if the proxy is behind a reverse proxy)", Default: ""}) + apiBaseUrl := parser.String("a", "apiBaseUrl", &argparse.Options{Help: "Base URL for proxying API commands", Default: ""}) // Inject environment variables as command line arguments args := os.Args for _, arg := range parser.GetArgs() { @@ -80,7 +81,8 @@ func LoadConfig() *Config { HttpListenAddress: *httpListenAddress, ScanTimeout: *scanTimeout, CacheMaxAge: *cacheMaxAge, - ProxyBaseURL: *proxyBaseUrl, + DashboardBaseURL: *dashboardBaseUrl, + ApiBaseUrl: *apiBaseUrl, } } diff --git a/internal/api/handlers/html.go b/internal/api/handlers/html.go index 8861456..90e91ec 100644 --- a/internal/api/handlers/html.go +++ b/internal/api/handlers/html.go @@ -44,7 +44,7 @@ func ShowDashboard(html fs.FS) http.HandlerFunc { PublicKey: publicKey, ShouldGenKeys: shouldGenKeys, Messages: messages, - BaseUrl: config.AppConfig.ProxyBaseURL, + BaseUrl: config.AppConfig.DashboardBaseURL, } if err := Dashboard(w, p, "", html); err != nil { log.Error("error showing dashboard", "error", err) @@ -69,7 +69,7 @@ func GenKeys(w http.ResponseWriter, r *http.Request) { Type: models.Error, }) } - base := config.AppConfig.ProxyBaseURL + base := config.AppConfig.DashboardBaseURL http.Redirect(w, r, base+"/dashboard", http.StatusSeeOther) } @@ -99,13 +99,13 @@ func RemoveKeys(w http.ResponseWriter, r *http.Request) { } control.CloseBleControl() - base := config.AppConfig.ProxyBaseURL + base := config.AppConfig.DashboardBaseURL http.Redirect(w, r, base+"/dashboard", http.StatusSeeOther) } func SendKey(w http.ResponseWriter, r *http.Request) { defer func() { - base := config.AppConfig.ProxyBaseURL + base := config.AppConfig.DashboardBaseURL http.Redirect(w, r, base+"/dashboard", http.StatusSeeOther) }() diff --git a/internal/api/routes/routes.go b/internal/api/routes/routes.go index 2c721c7..46be38a 100644 --- a/internal/api/routes/routes.go +++ b/internal/api/routes/routes.go @@ -13,31 +13,33 @@ import ( func SetupRoutes(static embed.FS, html embed.FS) *mux.Router { router := mux.NewRouter() - base := config.AppConfig.ProxyBaseURL + apiBase := config.AppConfig.ApiBaseUrl + dashboardBase := config.AppConfig.DashboardBaseURL // Define the endpoints - router.HandleFunc(base+"/api/1/vehicles/{vin}/command/{command}", handlers.VehicleCommand).Methods("POST") - router.HandleFunc(base+"/api/1/vehicles/{vin}/{command}", handlers.VehicleEndpoint).Methods("GET", "POST") - router.HandleFunc(base+"/api/proxy/1/vehicles/{vin}/{command}", handlers.ProxyCommand).Methods("GET") - router.HandleFunc(base+"/dashboard", handlers.ShowDashboard(html)).Methods("GET") - router.HandleFunc(base+"/gen_keys", handlers.GenKeys).Methods("GET") - router.HandleFunc(base+"/remove_keys", handlers.RemoveKeys).Methods("GET") - router.HandleFunc(base+"/send_key", handlers.SendKey).Methods("POST") + router.HandleFunc(apiBase+"/api/1/vehicles/{vin}/command/{command}", handlers.VehicleCommand).Methods("POST") + router.HandleFunc(apiBase+"/api/1/vehicles/{vin}/{command}", handlers.VehicleEndpoint).Methods("GET", "POST") + router.HandleFunc(apiBase+"/api/proxy/1/vehicles/{vin}/{command}", handlers.ProxyCommand).Methods("GET") + router.HandleFunc(dashboardBase+"/dashboard", handlers.ShowDashboard(html)).Methods("GET") + router.HandleFunc(dashboardBase+"/gen_keys", handlers.GenKeys).Methods("GET") + router.HandleFunc(dashboardBase+"/remove_keys", handlers.RemoveKeys).Methods("GET") + router.HandleFunc(dashboardBase+"/send_key", handlers.SendKey).Methods("POST") // Static files staticHandler := http.FileServer(http.FS(static)) - if base != "" { - staticHandler = http.StripPrefix(base, staticHandler) + if dashboardBase != "" { + staticHandler = http.StripPrefix(dashboardBase, staticHandler) } - router.PathPrefix(base + "/static/").Handler(staticHandler) + router.PathPrefix(dashboardBase + "/static/").Handler(staticHandler) // Redirect / to /dashboard - indexPath := base - if len(indexPath) == 0 { - indexPath = "/" + indexPath := dashboardBase + if len(indexPath) == 0 || indexPath[len(indexPath)-1] != '/' { + indexPath += "/" } router.HandleFunc(indexPath, func(w http.ResponseWriter, r *http.Request) { - http.Redirect(w, r, base+"/dashboard", http.StatusSeeOther) + log.Debugf("redirecting %s to %s", r.URL, dashboardBase+"/dashboard") + http.Redirect(w, r, dashboardBase+"/dashboard", http.StatusSeeOther) }) router.MethodNotAllowedHandler = http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { From fd5462dfcfb17f1c17394662a885570cbb776a55 Mon Sep 17 00:00:00 2001 From: Lenart Kos Date: Thu, 30 Jan 2025 21:35:58 +0100 Subject: [PATCH 22/41] Add support for `X-Ingress-Path` or `X-Forwarded-Prefix` --- internal/api/handlers/html.go | 8 +++---- internal/api/routes/routes.go | 43 ++++++++++++++++++++++++++--------- 2 files changed, 36 insertions(+), 15 deletions(-) diff --git a/internal/api/handlers/html.go b/internal/api/handlers/html.go index 90e91ec..c33ebcf 100644 --- a/internal/api/handlers/html.go +++ b/internal/api/handlers/html.go @@ -44,7 +44,7 @@ func ShowDashboard(html fs.FS) http.HandlerFunc { PublicKey: publicKey, ShouldGenKeys: shouldGenKeys, Messages: messages, - BaseUrl: config.AppConfig.DashboardBaseURL, + BaseUrl: r.PathValue("basePath"), } if err := Dashboard(w, p, "", html); err != nil { log.Error("error showing dashboard", "error", err) @@ -69,7 +69,7 @@ func GenKeys(w http.ResponseWriter, r *http.Request) { Type: models.Error, }) } - base := config.AppConfig.DashboardBaseURL + base := r.PathValue("basePath") http.Redirect(w, r, base+"/dashboard", http.StatusSeeOther) } @@ -99,13 +99,13 @@ func RemoveKeys(w http.ResponseWriter, r *http.Request) { } control.CloseBleControl() - base := config.AppConfig.DashboardBaseURL + base := r.PathValue("basePath") http.Redirect(w, r, base+"/dashboard", http.StatusSeeOther) } func SendKey(w http.ResponseWriter, r *http.Request) { defer func() { - base := config.AppConfig.DashboardBaseURL + base := r.PathValue("basePath") http.Redirect(w, r, base+"/dashboard", http.StatusSeeOther) }() diff --git a/internal/api/routes/routes.go b/internal/api/routes/routes.go index 46be38a..c29f52c 100644 --- a/internal/api/routes/routes.go +++ b/internal/api/routes/routes.go @@ -10,6 +10,26 @@ import ( "github.com/wimaha/TeslaBleHttpProxy/internal/api/handlers" ) +func basePathHandler(from string, wrapped http.HandlerFunc) http.HandlerFunc { + fromBase := config.AppConfig.ApiBaseUrl + if from == "dashboard" { + fromBase = config.AppConfig.DashboardBaseURL + } + return func(w http.ResponseWriter, r *http.Request) { + basePath := fromBase + if r.Header.Get("X-Ingress-Path") != "" { + log.Debug("ingress path", "from", from, "path", r.Header.Get("X-Ingress-Path")) + basePath = r.Header.Get("X-Ingress-Path") + } + if r.Header.Get("X-Forwarded-Prefix") != "" { + log.Debug("forwarded prefix", "from", from, "path", r.Header.Get("X-Forwarded-Prefix")) + basePath = r.Header.Get("X-Forwarded-Prefix") + } + r.SetPathValue("basePath", basePath) + wrapped(w, r) + } +} + func SetupRoutes(static embed.FS, html embed.FS) *mux.Router { router := mux.NewRouter() @@ -17,13 +37,13 @@ func SetupRoutes(static embed.FS, html embed.FS) *mux.Router { dashboardBase := config.AppConfig.DashboardBaseURL // Define the endpoints - router.HandleFunc(apiBase+"/api/1/vehicles/{vin}/command/{command}", handlers.VehicleCommand).Methods("POST") - router.HandleFunc(apiBase+"/api/1/vehicles/{vin}/{command}", handlers.VehicleEndpoint).Methods("GET", "POST") - router.HandleFunc(apiBase+"/api/proxy/1/vehicles/{vin}/{command}", handlers.ProxyCommand).Methods("GET") - router.HandleFunc(dashboardBase+"/dashboard", handlers.ShowDashboard(html)).Methods("GET") - router.HandleFunc(dashboardBase+"/gen_keys", handlers.GenKeys).Methods("GET") - router.HandleFunc(dashboardBase+"/remove_keys", handlers.RemoveKeys).Methods("GET") - router.HandleFunc(dashboardBase+"/send_key", handlers.SendKey).Methods("POST") + router.HandleFunc(apiBase+"/api/1/vehicles/{vin}/command/{command}", basePathHandler("api", handlers.VehicleCommand)).Methods("POST") + router.HandleFunc(apiBase+"/api/1/vehicles/{vin}/{command}", basePathHandler("api", handlers.VehicleEndpoint)).Methods("GET", "POST") + router.HandleFunc(apiBase+"/api/proxy/1/vehicles/{vin}/{command}", basePathHandler("api", handlers.ProxyCommand)).Methods("GET") + router.HandleFunc(dashboardBase+"/dashboard", basePathHandler("dashboard", handlers.ShowDashboard(html))).Methods("GET") + router.HandleFunc(dashboardBase+"/gen_keys", basePathHandler("dashboard", handlers.GenKeys)).Methods("GET") + router.HandleFunc(dashboardBase+"/remove_keys", basePathHandler("dashboard", handlers.RemoveKeys)).Methods("GET") + router.HandleFunc(dashboardBase+"/send_key", basePathHandler("dashboard", handlers.SendKey)).Methods("POST") // Static files staticHandler := http.FileServer(http.FS(static)) @@ -37,10 +57,11 @@ func SetupRoutes(static embed.FS, html embed.FS) *mux.Router { if len(indexPath) == 0 || indexPath[len(indexPath)-1] != '/' { indexPath += "/" } - router.HandleFunc(indexPath, func(w http.ResponseWriter, r *http.Request) { - log.Debugf("redirecting %s to %s", r.URL, dashboardBase+"/dashboard") - http.Redirect(w, r, dashboardBase+"/dashboard", http.StatusSeeOther) - }) + router.HandleFunc(indexPath, basePathHandler("dashboard", func(w http.ResponseWriter, r *http.Request) { + basePath := r.PathValue("basePath") + log.Debugf("redirecting %s to %s", r.URL, basePath+"/dashboard") + http.Redirect(w, r, basePath+"/dashboard", http.StatusSeeOther) + })) router.MethodNotAllowedHandler = http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { log.Debug("405 method not allowed", "method", r.Method, "path", r.URL.Path) From 1bde67f6c873921ef4f0b1bc2c924a499af9b6e8 Mon Sep 17 00:00:00 2001 From: Lenart Kos Date: Thu, 30 Jan 2025 22:57:28 +0100 Subject: [PATCH 23/41] Update README to include Home Assistant addon installation instructions --- README.md | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 02580f3..9be22be 100644 --- a/README.md +++ b/README.md @@ -7,6 +7,7 @@ The program stores the received requests in a queue and processes them one by on ## Table of Contents - [How to install](#how-to-install) + - [Home assistant addon](#home-assistant-addon) - [Docker compose](#docker-compose) - [Build yourself](#build-yourself) - [Generate key for vehicle](#generate-key-for-vehicle) @@ -18,7 +19,14 @@ The program stores the received requests in a queue and processes them one by on ## How to install -You can either compile and use the Go program yourself or install it in a Docker container. ([detailed instruction](docs/installation.md)) +You can either compile and use the Go program yourself or install it as a Home assistant addon or in a Docker container. ([detailed instruction](docs/installation.md)) + +### Home assistant addon + +This proxy is availabile in the [TeslaBle2Mqtt-addon](/~https://github.com/Lenart12/TeslaBle2Mqtt-addon) repository, included as part of `TeslaBle2Mqtt` addon or as a standalone `TeslaBleHttpProxy` addon. + +[![Open your Home Assistant instance and show the add add-on repository dialog with a specific repository URL pre-filled.](https://my.home-assistant.io/badges/supervisor_add_addon_repository.svg)](https://my.home-assistant.io/redirect/supervisor_add_addon_repository/?repository_url=/~https://github.com/Lenart12/TeslaBle2Mqtt-addon) + ### Docker compose From 10a5d248e5742e5e2ae770605d2d1fa91d36fd80 Mon Sep 17 00:00:00 2001 From: Lenart Kos Date: Thu, 30 Jan 2025 23:07:08 +0100 Subject: [PATCH 24/41] Update README with new configuration options --- README.md | 6 +++++- docs/environment_variables.md | 16 ++++++++++++++++ 2 files changed, 21 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 9be22be..a81666a 100644 --- a/README.md +++ b/README.md @@ -66,7 +66,8 @@ go build . usage: TeslaBleHttpProxy [-h|--help] [-l|--logLevel ""] [-b|--httpListenAddress ""] [-s|--scanTimeout ] [-c|--cacheMaxAge ] [-k|--keys - ""] + ""] [-d|--dashboardBaseUrl ""] + [-a|--apiBaseUrl ""] Proxy for Tesla BLE commands over HTTP @@ -81,6 +82,9 @@ Arguments: -c --cacheMaxAge Time in seconds for Cache-Control header (0 = no cache). Default: 5 -k --keys Path to public and private keys. Default: key + -d --dashboardBaseUrl Base URL for dashboard (Useful if the proxy is + behind a reverse proxy). Default: + -a --apiBaseUrl Base URL for proxying API commands. Default: ``` Please remember to create an empty folder called `key` where the keys can be stored later. diff --git a/docs/environment_variables.md b/docs/environment_variables.md index 916409f..21a2211 100644 --- a/docs/environment_variables.md +++ b/docs/environment_variables.md @@ -14,6 +14,22 @@ This is the value that will be set in Cache-Control header for vehicle data and This is the address and port to listen for HTTP requests. (Default: :8080) +## keys + +Path to public and private keys. (Default: key) + +## dashboardBaseUrl + +Base URL for dashboard (Useful if the proxy is behind a reverse proxy). (Default: empty) + +## apiBaseUrl + +Base URL for proxying API commands. (Default: empty) + +> [!NOTE] +> It will adjust its base path depending on the `X-Ingress-Path` and `X-Forwarded-Prefix` +> headers, regardless of what either of the base url is set to. + # Example ## Docker compose From dc233a6f63b0faa8df35a1c3db178c1509ebf3ad Mon Sep 17 00:00:00 2001 From: Lenart Kos Date: Fri, 31 Jan 2025 13:07:52 +0100 Subject: [PATCH 25/41] Ignore json errors before validation --- internal/api/handlers/tesla.go | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) diff --git a/internal/api/handlers/tesla.go b/internal/api/handlers/tesla.go index 61e999f..5bc5475 100644 --- a/internal/api/handlers/tesla.go +++ b/internal/api/handlers/tesla.go @@ -3,7 +3,6 @@ package handlers import ( "encoding/json" "fmt" - "io" "net/http" "strings" "sync" @@ -100,11 +99,8 @@ func VehicleCommand(w http.ResponseWriter, r *http.Request) { // Check if the body is empty if err := json.NewDecoder(r.Body).Decode(&body); err != nil { - if err != io.EOF { - log.Error("decoding body", "err", err) - writeResponseWithStatus(w, &models.Response{Vin: vin, Command: command, Result: false, Reason: "Failed to decode body"}) - return - } + log.Debug("decoding body", "err", err) + // If json fails we try to validate anyways } if err := commands.ValidateFleetVehicleCommand(command, body); err != nil { From b971a050d75b4efdfb21c25b7cd5151fb5d25ed4 Mon Sep 17 00:00:00 2001 From: Lenart Kos Date: Sat, 1 Feb 2025 10:25:12 +0100 Subject: [PATCH 26/41] Report null rssi instead of huge value if no connection --- internal/ble/control/control.go | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/internal/ble/control/control.go b/internal/ble/control/control.go index a657d8d..985dd0c 100644 --- a/internal/ble/control/control.go +++ b/internal/ble/control/control.go @@ -4,7 +4,6 @@ import ( "context" "encoding/json" "fmt" - "math" "os" "strings" "time" @@ -144,8 +143,8 @@ func processIfConnectionStatusCommand(command *commands.Command, operated bool) resp = map[string]interface{}{ "local_name": ble.VehicleLocalName(command.Vin), "connectable": false, - "address": "", - "rssi": math.MinInt32, + "address": nil, + "rssi": nil, "operated": false, } } From f7c26f0db8c258c26bc6c5474f00e92c06a9b8fc Mon Sep 17 00:00:00 2001 From: Lenart Kos Date: Sun, 9 Feb 2025 18:13:01 +0100 Subject: [PATCH 27/41] README fixup --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index a81666a..a9f086d 100644 --- a/README.md +++ b/README.md @@ -256,7 +256,7 @@ Get body controller state: ### Connection status Get BLE connection status of the vehicle -`GET http://localhost:8080/api/proxy/1/vehicles/LRWYGCFSXPC882647/connection_status` +`GET http://localhost:8080/api/proxy/1/vehicles/{VIN}/connection_status` - `address` - `connectable` - `local_name` From dc04bd0f6daa3bc357e4918998dbd95e0abfd3a9 Mon Sep 17 00:00:00 2001 From: Lenart Kos Date: Wed, 12 Feb 2025 00:10:11 +0100 Subject: [PATCH 28/41] Replace bluetooth backend --- go.mod | 22 ++++++++----- go.sum | 58 +++++++++++---------------------- internal/ble/control/control.go | 22 ++++++------- 3 files changed, 43 insertions(+), 59 deletions(-) diff --git a/go.mod b/go.mod index 69dafa3..0ff68ec 100644 --- a/go.mod +++ b/go.mod @@ -3,34 +3,38 @@ module github.com/wimaha/TeslaBleHttpProxy go 1.23.3 require ( + github.com/akamensky/argparse v1.4.0 github.com/charmbracelet/log v0.4.0 github.com/gorilla/mux v1.8.1 + github.com/pkg/errors v0.9.1 github.com/teslamotors/vehicle-command v0.3.3-0.20250128004836-ebad42aaa852 + google.golang.org/protobuf v1.34.2 ) require ( - github.com/JuulLabs-OSS/cbgo v0.0.2 // indirect - github.com/akamensky/argparse v1.4.0 // indirect github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect github.com/charmbracelet/lipgloss v0.12.1 // indirect github.com/charmbracelet/x/ansi v0.1.4 // indirect github.com/cronokirby/saferith v0.33.0 // indirect - github.com/go-ble/ble v0.0.0-20240122180141-8c5522f54333 // indirect github.com/go-logfmt/logfmt v0.6.0 // indirect + github.com/go-ole/go-ole v1.2.6 // indirect + github.com/godbus/dbus/v5 v5.1.0 // indirect github.com/golang-jwt/jwt/v5 v5.2.1 // indirect github.com/lucasb-eyer/go-colorful v1.2.0 // indirect - github.com/mattn/go-colorable v0.1.13 // indirect github.com/mattn/go-isatty v0.0.20 // indirect github.com/mattn/go-runewidth v0.0.16 // indirect - github.com/mgutz/ansi v0.0.0-20200706080929-d51e80ef957d // indirect - github.com/mgutz/logxi v0.0.0-20161027140823-aebf8a7d67ab // indirect github.com/muesli/termenv v0.15.2 // indirect - github.com/pkg/errors v0.9.1 // indirect - github.com/raff/goble v0.0.0-20200327175727-d63360dcfd80 // indirect github.com/rivo/uniseg v0.4.7 // indirect + github.com/saltosystems/winrt-go v0.0.0-20240509164145-4f7860a3bd2b // indirect github.com/sirupsen/logrus v1.9.3 // indirect + github.com/soypat/cyw43439 v0.0.0-20241116210509-ae1ce0e084c5 // indirect + github.com/soypat/seqs v0.0.0-20240527012110-1201bab640ef // indirect github.com/stretchr/testify v1.10.0 // indirect + github.com/tinygo-org/cbgo v0.0.4 // indirect + github.com/tinygo-org/pio v0.0.0-20231216154340-cd888eb58899 // indirect golang.org/x/exp v0.0.0-20240808152545-0cdaa3abc0fa // indirect golang.org/x/sys v0.24.0 // indirect - google.golang.org/protobuf v1.34.2 // indirect + tinygo.org/x/bluetooth v0.11.0 // indirect ) + +replace github.com/teslamotors/vehicle-command => github.com/Lenart12/vehicle-command v0.0.0-20250211223038-6948c3358933 diff --git a/go.sum b/go.sum index cc4ba7f..cddb87b 100644 --- a/go.sum +++ b/go.sum @@ -1,7 +1,5 @@ -github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU= -github.com/JuulLabs-OSS/cbgo v0.0.1/go.mod h1:L4YtGP+gnyD84w7+jN66ncspFRfOYB5aj9QSXaFHmBA= -github.com/JuulLabs-OSS/cbgo v0.0.2 h1:gCDyT0+EPuI8GOFyvAksFcVD2vF4CXBAVwT6uVnD9oo= -github.com/JuulLabs-OSS/cbgo v0.0.2/go.mod h1:L4YtGP+gnyD84w7+jN66ncspFRfOYB5aj9QSXaFHmBA= +github.com/Lenart12/vehicle-command v0.0.0-20250211223038-6948c3358933 h1:+aI83g2BV5CwQqAFZuPf8NDUAPA7gl9FhGq30fkahAM= +github.com/Lenart12/vehicle-command v0.0.0-20250211223038-6948c3358933/go.mod h1:TUE4TpdW7f82y7cXEEs/RuWWDtcnVFu4Q62solTvRxA= github.com/akamensky/argparse v1.4.0 h1:YGzvsTqCvbEZhL8zZu2AiA5nq805NZh75JNj4ajn1xc= github.com/akamensky/argparse v1.4.0/go.mod h1:S5kwC7IuDcEr5VeXtGPRVZ5o/FdhcMlQz4IZQuw64xA= github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k= @@ -12,16 +10,17 @@ github.com/charmbracelet/log v0.4.0 h1:G9bQAcx8rWA2T3pWvx7YtPTPwgqpk7D68BX21IRW8 github.com/charmbracelet/log v0.4.0/go.mod h1:63bXt/djrizTec0l11H20t8FDSvA4CRZJ1KH22MdptM= github.com/charmbracelet/x/ansi v0.1.4 h1:IEU3D6+dWwPSgZ6HBH+v6oUuZ/nVawMiWj5831KfiLM= github.com/charmbracelet/x/ansi v0.1.4/go.mod h1:dk73KoMTT5AX5BsX0KrqhsTqAnhZZoCBjs7dGWp4Ktw= -github.com/cpuguy83/go-md2man/v2 v2.0.0-20190314233015-f79a8a8ca69d/go.mod h1:maD7wRr/U5Z6m/iR4s+kqSMx2CaBsrgA7czyZG/E6dU= github.com/cronokirby/saferith v0.33.0 h1:TgoQlfsD4LIwx71+ChfRcIpjkw+RPOapDEVxa+LhwLo= github.com/cronokirby/saferith v0.33.0/go.mod h1:QKJhjoqUtBsXCAVEjw38mFqoi7DebT7kthcD7UzbnoA= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= -github.com/go-ble/ble v0.0.0-20240122180141-8c5522f54333 h1:bQK6D51cNzMSTyAf0HtM30V2IbljHTDam7jru9JNlJA= -github.com/go-ble/ble v0.0.0-20240122180141-8c5522f54333/go.mod h1:fFJl/jD/uyILGBeD5iQ8tYHrPlJafyqCJzAyTHNJ1Uk= github.com/go-logfmt/logfmt v0.6.0 h1:wGYYu3uicYdqXVgoYbvnkrPVXkuLM1p1ifugDMEdRi4= github.com/go-logfmt/logfmt v0.6.0/go.mod h1:WYhtIu8zTZfxdn5+rREduYbwxfcBr/Vr6KEVveWlfTs= +github.com/go-ole/go-ole v1.2.6 h1:/Fpf6oFPoeFik9ty7siob0G6Ke8QvQEuVcuChpwXzpY= +github.com/go-ole/go-ole v1.2.6/go.mod h1:pprOEPIfldk/42T2oK7lQ4v4JSDwmV0As9GaiUsvbm0= +github.com/godbus/dbus/v5 v5.1.0 h1:4KLkAxT3aOY8Li4FRJe/KvhoNFFxo0m6fNuFUO8QJUk= +github.com/godbus/dbus/v5 v5.1.0/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA= 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.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= @@ -31,69 +30,50 @@ github.com/gorilla/mux v1.8.1/go.mod h1:AKf9I4AEqPTmMytcMc0KkNouC66V3BtZ4qD5fmWS github.com/konsorten/go-windows-terminal-sequences v1.0.1/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ= github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY= github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= -github.com/mattn/go-colorable v0.1.6/go.mod h1:u6P/XSegPjTcexA+o6vUJrdnUu04hMope9wVRipJSqc= -github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA= -github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg= -github.com/mattn/go-isatty v0.0.12/go.mod h1:cbi8OIDigv2wuxKPP5vlRcQ1OAZbq2CE4Kysco4FUpU= -github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc= github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= -github.com/mgutz/ansi v0.0.0-20170206155736-9520e82c474b/go.mod h1:01TrycV0kFyexm33Z7vhZRXopbI8J3TDReVlkTgMUxE= -github.com/mgutz/ansi v0.0.0-20200706080929-d51e80ef957d h1:5PJl274Y63IEHC+7izoQE9x6ikvDFZS2mDVS3drnohI= -github.com/mgutz/ansi v0.0.0-20200706080929-d51e80ef957d/go.mod h1:01TrycV0kFyexm33Z7vhZRXopbI8J3TDReVlkTgMUxE= -github.com/mgutz/logxi v0.0.0-20161027140823-aebf8a7d67ab h1:n8cgpHzJ5+EDyDri2s/GC7a9+qK3/YEGnBsd0uS/8PY= -github.com/mgutz/logxi v0.0.0-20161027140823-aebf8a7d67ab/go.mod h1:y1pL58r5z2VvAjeG1VLGc8zOQgSOzbKN7kMHPvFXJ+8= github.com/muesli/termenv v0.15.2 h1:GohcuySI0QmI3wN8Ok9PtKGkgkFIk7y6Vpb5PvrY+Wo= github.com/muesli/termenv v0.15.2/go.mod h1:Epx+iuz8sNs7mNKhxzH4fWXGNpZwUaJKRS1noLXviQ8= -github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= -github.com/raff/goble v0.0.0-20190909174656-72afc67d6a99/go.mod h1:CxaUhijgLFX0AROtH5mluSY71VqpjQBw9JXE2UKZmc4= -github.com/raff/goble v0.0.0-20200327175727-d63360dcfd80 h1:IZkjNgPZXcE4USkGzmJQyHco3KFLmhcLyFdxCOiY6cQ= -github.com/raff/goble v0.0.0-20200327175727-d63360dcfd80/go.mod h1:CxaUhijgLFX0AROtH5mluSY71VqpjQBw9JXE2UKZmc4= github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= -github.com/russross/blackfriday/v2 v2.0.1/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= -github.com/shurcooL/sanitized_anchor_name v1.0.0/go.mod h1:1NzhyTcUVG4SuEtjjoZeVRXNmyL/1OwPU0+IJeTBvfc= +github.com/saltosystems/winrt-go v0.0.0-20240509164145-4f7860a3bd2b h1:du3zG5fd8snsFN6RBoLA7fpaYV9ZQIsyH9snlk2Zvik= +github.com/saltosystems/winrt-go v0.0.0-20240509164145-4f7860a3bd2b/go.mod h1:CIltaIm7qaANUIvzr0Vmz71lmQMAIbGJ7cvgzX7FMfA= github.com/sirupsen/logrus v1.5.0/go.mod h1:+F7Ogzej0PZc/94MaYx/nvG9jOFMD2osvC3s+Squfpo= github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ= github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ= +github.com/soypat/cyw43439 v0.0.0-20241116210509-ae1ce0e084c5 h1:arwJFX1x5zq+wUp5ADGgudhMQEXKNMQOmTh+yYgkwzw= +github.com/soypat/cyw43439 v0.0.0-20241116210509-ae1ce0e084c5/go.mod h1:1Otjk6PRhfzfcVHeWMEeku/VntFqWghUwuSQyivb2vE= +github.com/soypat/seqs v0.0.0-20240527012110-1201bab640ef h1:phH95I9wANjTYw6bSYLZDQfNvao+HqYDom8owbNa0P4= +github.com/soypat/seqs v0.0.0-20240527012110-1201bab640ef/go.mod h1:oCVCNGCHMKoBj97Zp9znLbQ1nHxpkmOY9X+UAGzOxc8= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= -github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY= -github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= 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.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= -github.com/teslamotors/vehicle-command v0.3.3-0.20250128004836-ebad42aaa852 h1:TrYeM7qS2mB80Zl8T+xqlzxNG6dEmpRaK6oPrDGQBIo= -github.com/teslamotors/vehicle-command v0.3.3-0.20250128004836-ebad42aaa852/go.mod h1:ZVR0KE8v3IrQUJAuBrxKkRjPZOVI0oxEqBj8x1eFpDQ= -github.com/urfave/cli v1.22.2/go.mod h1:Gos4lmkARVdJ6EkW0WaNv/tZAAMe9V7XWyB60NtXRu0= -github.com/wimaha/ble_BleConnectFix v0.0.0-20240822192426-3f74826c1268 h1:36sDJ2qts2oT/Fy/Wi6MR15C+LHl2+ZUzLp6UaqhS9c= -github.com/wimaha/ble_BleConnectFix v0.0.0-20240822192426-3f74826c1268/go.mod h1:fFJl/jD/uyILGBeD5iQ8tYHrPlJafyqCJzAyTHNJ1Uk= -github.com/wimaha/vehicle-command v0.0.4 h1:UqwJXoWps9lovIqzAGQepaTO71kQIX+6Ie9dbquaPBg= -github.com/wimaha/vehicle-command v0.0.4/go.mod h1:T9UGDQPnIE08bjUCEjdDVcgCw3yfP4BzslizI03yRcI= +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/tinygo-org/pio v0.0.0-20231216154340-cd888eb58899 h1:/DyaXDEWMqoVUVEJVJIlNk1bXTbFs8s3Q4GdPInSKTQ= +github.com/tinygo-org/pio v0.0.0-20231216154340-cd888eb58899/go.mod h1:LU7Dw00NJ+N86QkeTGjMLNkYcEYMor6wTDpTCu0EaH8= golang.org/x/exp v0.0.0-20240808152545-0cdaa3abc0fa h1:ELnwvuAXPNtPk1TJRuGkI9fDTwym6AYBu0qzT8AcHdI= golang.org/x/exp v0.0.0-20240808152545-0cdaa3abc0fa/go.mod h1:akd2r19cwCdwSwWeIdzYQGa/EZZyqcOdwWiwj5L5eKQ= 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.0.0-20190916202348-b4ddaad3f8a3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.24.0 h1:Twjiwq9dn6R1fQcyiK+wQyHWfaz/BJB+YIpzU/Cv3Xg= golang.org/x/sys v0.24.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= 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 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= -gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +tinygo.org/x/bluetooth v0.11.0 h1:32ludjNnqz6RyVRpmw2qgod7NvDePbBTWXkJm6jj4cg= +tinygo.org/x/bluetooth v0.11.0/go.mod h1:XLRopLvxWmIbofpZSXc7BGGCpgFOV5lrZ1i/DQN0BCw= diff --git a/internal/ble/control/control.go b/internal/ble/control/control.go index 985dd0c..d67262d 100644 --- a/internal/ble/control/control.go +++ b/internal/ble/control/control.go @@ -36,7 +36,7 @@ func CloseBleControl() { type BleControl struct { privateKey protocol.ECDHPrivateKey - operatedBeacon *ble.Advertisement + operatedBeacon *ble.ScanResult infotainmentSession bool commandStack chan commands.Command @@ -106,11 +106,11 @@ func processIfConnectionStatusCommand(command *commands.Command, operated bool) command.Response.Result = true } - var beacon ble.Advertisement = nil + var beacon *ble.ScanResult = nil if operated { if BleControlInstance.operatedBeacon != nil { - beacon = *BleControlInstance.operatedBeacon + beacon = BleControlInstance.operatedBeacon } else { log.Warn("operated beacon is nil but operated is true") } @@ -133,10 +133,10 @@ func processIfConnectionStatusCommand(command *commands.Command, operated bool) var resp map[string]interface{} if beacon != nil { resp = map[string]interface{}{ - "local_name": beacon.LocalName(), - "connectable": beacon.Connectable(), - "address": beacon.Addr().String(), - "rssi": beacon.RSSI(), + "local_name": beacon.LocalName, + "connectable": true, + "address": beacon.Address.String(), + "rssi": beacon.RSSI, "operated": operated, } } else { @@ -291,7 +291,7 @@ func (bc *BleControl) TryConnectToVehicle(ctx context.Context, firstCommand *com } defer cancelScan() - beacon, err := ble.ScanVehicleBeacon(scanCtx, firstCommand.Vin) + scanResult, err := ble.ScanVehicleBeacon(scanCtx, firstCommand.Vin) if err != nil { if strings.Contains(err.Error(), "operation not permitted") { // The underlying BLE package calls HCIDEVDOWN on the BLE device, presumably as a @@ -304,10 +304,10 @@ func (bc *BleControl) TryConnectToVehicle(ctx context.Context, firstCommand *com } } - log.Debug("beacon found", "localName", beacon.LocalName(), "addr", beacon.Addr(), "rssi", beacon.RSSI()) + log.Debug("beacon found", "localName", scanResult.LocalName, "addr", scanResult.Address.String(), "rssi", scanResult.RSSI) log.Debug("dialing to vehicle ...") - conn, err = ble.NewConnectionToBleTarget(ctx, firstCommand.Vin, beacon) + conn, err = ble.NewConnectionToBleTarget(ctx, firstCommand.Vin, scanResult) if err != nil { return nil, nil, true, fmt.Errorf("failed to connect to vehicle (A): %s", err) } @@ -351,7 +351,7 @@ func (bc *BleControl) TryConnectToVehicle(ctx context.Context, firstCommand *com log.Info("Key-Request connection established") } - bc.operatedBeacon = &beacon + bc.operatedBeacon = scanResult // everything fine shouldDefer = false From feadd143be66703531e6966ba0e0bdc98ba1f99f Mon Sep 17 00:00:00 2001 From: Lenart Kos Date: Wed, 12 Feb 2025 00:59:32 +0100 Subject: [PATCH 29/41] Allow specifying different BT adapter --- config/config.go | 9 +++++++++ docs/environment_variables.md | 7 +++++++ go.mod | 3 ++- go.sum | 4 ++-- internal/ble/control/control.go | 7 +------ main.go | 10 ++++++++++ 6 files changed, 31 insertions(+), 9 deletions(-) diff --git a/config/config.go b/config/config.go index 9fe8c13..e537001 100644 --- a/config/config.go +++ b/config/config.go @@ -4,6 +4,7 @@ import ( "fmt" "net/url" "os" + "strings" "github.com/akamensky/argparse" "github.com/charmbracelet/log" @@ -19,6 +20,7 @@ type Config struct { CacheMaxAge int // Seconds to cache BLE responses DashboardBaseURL string // Base URL for proxying dashboard (Useful if the proxy is behind a reverse proxy) ApiBaseUrl string // Base URL for proxying BLE commands (Useful if the proxy is behind a reverse proxy) + BtAdapterID string // The Bluetooth adapter to use } var AppConfig *Config @@ -56,6 +58,12 @@ func LoadConfig() *Config { }}) dashboardBaseUrl := parser.String("d", "dashboardBaseUrl", &argparse.Options{Help: "Base URL for dashboard (Useful if the proxy is behind a reverse proxy)", Default: ""}) apiBaseUrl := parser.String("a", "apiBaseUrl", &argparse.Options{Help: "Base URL for proxying API commands", Default: ""}) + btAdapterID := parser.String("B", "btAdapter", &argparse.Options{Help: "Bluetooth adapter ID to use (\"hciX\")", Default: "Default adapter", Validate: func(args []string) error { + if !strings.HasPrefix(args[0], "hci") { + return fmt.Errorf("invalid Bluetooth adapter ID (must start with 'hci')") + } + return nil + }}) // Inject environment variables as command line arguments args := os.Args for _, arg := range parser.GetArgs() { @@ -83,6 +91,7 @@ func LoadConfig() *Config { CacheMaxAge: *cacheMaxAge, DashboardBaseURL: *dashboardBaseUrl, ApiBaseUrl: *apiBaseUrl, + BtAdapterID: *btAdapterID, } } diff --git a/docs/environment_variables.md b/docs/environment_variables.md index 21a2211..515bd7c 100644 --- a/docs/environment_variables.md +++ b/docs/environment_variables.md @@ -14,6 +14,13 @@ This is the value that will be set in Cache-Control header for vehicle data and This is the address and port to listen for HTTP requests. (Default: :8080) +## bdAdapter + +Specify which Bluetooth adapter to use by setting this option to its Bluetooth device ID. +The correct ID is in the format of `hciX`. + +Only supported on Linux. + ## keys Path to public and private keys. (Default: key) diff --git a/go.mod b/go.mod index 0ff68ec..9770b82 100644 --- a/go.mod +++ b/go.mod @@ -37,4 +37,5 @@ require ( tinygo.org/x/bluetooth v0.11.0 // indirect ) -replace github.com/teslamotors/vehicle-command => github.com/Lenart12/vehicle-command v0.0.0-20250211223038-6948c3358933 +// Replace untill /~https://github.com/teslamotors/vehicle-command/pull/373 is merged +replace github.com/teslamotors/vehicle-command => github.com/Lenart12/vehicle-command v0.0.0-20250211233450-8e999651b66d diff --git a/go.sum b/go.sum index cddb87b..51a7b35 100644 --- a/go.sum +++ b/go.sum @@ -1,5 +1,5 @@ -github.com/Lenart12/vehicle-command v0.0.0-20250211223038-6948c3358933 h1:+aI83g2BV5CwQqAFZuPf8NDUAPA7gl9FhGq30fkahAM= -github.com/Lenart12/vehicle-command v0.0.0-20250211223038-6948c3358933/go.mod h1:TUE4TpdW7f82y7cXEEs/RuWWDtcnVFu4Q62solTvRxA= +github.com/Lenart12/vehicle-command v0.0.0-20250211233450-8e999651b66d h1:ymomtvZhgZAm6eqXEHU5U6IbdjV+HXgp03ewfupqYn4= +github.com/Lenart12/vehicle-command v0.0.0-20250211233450-8e999651b66d/go.mod h1:TUE4TpdW7f82y7cXEEs/RuWWDtcnVFu4Q62solTvRxA= github.com/akamensky/argparse v1.4.0 h1:YGzvsTqCvbEZhL8zZu2AiA5nq805NZh75JNj4ajn1xc= github.com/akamensky/argparse v1.4.0/go.mod h1:S5kwC7IuDcEr5VeXtGPRVZ5o/FdhcMlQz4IZQuw64xA= github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k= diff --git a/internal/ble/control/control.go b/internal/ble/control/control.go index d67262d..296b2c3 100644 --- a/internal/ble/control/control.go +++ b/internal/ble/control/control.go @@ -4,7 +4,6 @@ import ( "context" "encoding/json" "fmt" - "os" "strings" "time" @@ -293,11 +292,7 @@ func (bc *BleControl) TryConnectToVehicle(ctx context.Context, firstCommand *com scanResult, err := ble.ScanVehicleBeacon(scanCtx, firstCommand.Vin) if err != nil { - if strings.Contains(err.Error(), "operation not permitted") { - // The underlying BLE package calls HCIDEVDOWN on the BLE device, presumably as a - // heavy-handed way of dealing with devices that are in a bad state. - return nil, nil, false, fmt.Errorf("failed to scan for vehicle: %s\nTry again after granting this application CAP_NET_ADMIN:\nsudo setcap 'cap_net_admin=eip' \"$(which %s)\"", err, os.Args[0]) - } else if scanCtx.Err() != nil { + if scanCtx.Err() != nil { return nil, nil, false, fmt.Errorf("vehicle not in range: %s", err) } else { return nil, nil, true, fmt.Errorf("failed to scan for vehicle: %s", err) diff --git a/main.go b/main.go index a6db436..f5c8492 100644 --- a/main.go +++ b/main.go @@ -5,6 +5,7 @@ import ( "net/http" "github.com/charmbracelet/log" + "github.com/teslamotors/vehicle-command/pkg/connector/ble" "github.com/wimaha/TeslaBleHttpProxy/config" "github.com/wimaha/TeslaBleHttpProxy/internal/api/routes" "github.com/wimaha/TeslaBleHttpProxy/internal/ble/control" @@ -25,6 +26,15 @@ func main() { level, _ := log.ParseLevel(config.AppConfig.LogLevel) log.SetLevel(level) + btAdapterId := "" + if config.AppConfig.BtAdapterID != "Default adapter" { + btAdapterId = config.AppConfig.BtAdapterID + log.Debug("Using Bluetooth adapter:", "adapter", btAdapterId) + } + err := ble.InitAdapterWithID(btAdapterId) + if err != nil { + log.Fatal("Failed to initialize Bluetooth adapter:", "err", err) + } control.SetupBleControl() router := routes.SetupRoutes(static, html) From 4449d1250ee442e7538312882b3ee81a9c880677 Mon Sep 17 00:00:00 2001 From: Lenart Kos Date: Wed, 12 Feb 2025 09:59:57 +0100 Subject: [PATCH 30/41] Fix bug in `ble.ScanVehicleBeacon` --- go.mod | 2 +- go.sum | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/go.mod b/go.mod index 9770b82..32c5254 100644 --- a/go.mod +++ b/go.mod @@ -38,4 +38,4 @@ require ( ) // Replace untill /~https://github.com/teslamotors/vehicle-command/pull/373 is merged -replace github.com/teslamotors/vehicle-command => github.com/Lenart12/vehicle-command v0.0.0-20250211233450-8e999651b66d +replace github.com/teslamotors/vehicle-command => github.com/Lenart12/vehicle-command v0.0.0-20250212085739-055ef3f73bd2 diff --git a/go.sum b/go.sum index 51a7b35..7f3cbbc 100644 --- a/go.sum +++ b/go.sum @@ -1,5 +1,5 @@ -github.com/Lenart12/vehicle-command v0.0.0-20250211233450-8e999651b66d h1:ymomtvZhgZAm6eqXEHU5U6IbdjV+HXgp03ewfupqYn4= -github.com/Lenart12/vehicle-command v0.0.0-20250211233450-8e999651b66d/go.mod h1:TUE4TpdW7f82y7cXEEs/RuWWDtcnVFu4Q62solTvRxA= +github.com/Lenart12/vehicle-command v0.0.0-20250212085739-055ef3f73bd2 h1:SB8LxlLQYDvZG/QyYS1WLoHc95V/S4wpibSGdhxHJ+E= +github.com/Lenart12/vehicle-command v0.0.0-20250212085739-055ef3f73bd2/go.mod h1:TUE4TpdW7f82y7cXEEs/RuWWDtcnVFu4Q62solTvRxA= github.com/akamensky/argparse v1.4.0 h1:YGzvsTqCvbEZhL8zZu2AiA5nq805NZh75JNj4ajn1xc= github.com/akamensky/argparse v1.4.0/go.mod h1:S5kwC7IuDcEr5VeXtGPRVZ5o/FdhcMlQz4IZQuw64xA= github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k= From 4c8ea6c110e00567bb77d0745ba295b7d91bec2d Mon Sep 17 00:00:00 2001 From: Lenart Kos Date: Wed, 12 Feb 2025 14:05:49 +0100 Subject: [PATCH 31/41] Update docker settings to use dbus not host network --- README.md | 9 ++++----- docker-compose.yml | 8 ++------ go.mod | 2 +- go.sum | 4 ++-- main.go | 6 +++++- 5 files changed, 14 insertions(+), 15 deletions(-) diff --git a/README.md b/README.md index a9f086d..71b4463 100644 --- a/README.md +++ b/README.md @@ -37,19 +37,18 @@ services: tesla-ble-http-proxy: image: wimaha/tesla-ble-http-proxy container_name: tesla-ble-http-proxy + ports: + - "8080:8080" # Expose HTTP server port environment: - cacheMaxAge=5 # Optional, but recommended to set this to anything more than 0 if you are using the vehicle data volumes: - ~/TeslaBleHttpProxy/key:/key - /var/run/dbus:/var/run/dbus restart: always - privileged: true - network_mode: host - cap_add: - - NET_ADMIN - - SYS_ADMIN ``` +Before running the proxy make sure that bluez and dbus is installed on the host (`sudo apt install bluez dbus`). + Please remember to create an empty folder where the keys can be stored later. In this example, it is `~/TeslaBleHttpProxy/key`. Pull and start TeslaBleHttpProxy with `docker compose up -d`. diff --git a/docker-compose.yml b/docker-compose.yml index d755b66..2c654f5 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -3,16 +3,12 @@ services: tesla-ble-http-proxy: image: wimaha/tesla-ble-http-proxy:latest container_name: tesla-ble-http-proxy + ports: + - "8080:8080" volumes: - ~/TeslaBleHttpProxy/key:/key - /var/run/dbus:/var/run/dbus restart: always - privileged: true - network_mode: host - cap_add: - - NET_ADMIN - - SYS_ADMIN environment: #optional logLevel: debug httpListenAddress: :8080 - \ No newline at end of file diff --git a/go.mod b/go.mod index 32c5254..b5c2de2 100644 --- a/go.mod +++ b/go.mod @@ -38,4 +38,4 @@ require ( ) // Replace untill /~https://github.com/teslamotors/vehicle-command/pull/373 is merged -replace github.com/teslamotors/vehicle-command => github.com/Lenart12/vehicle-command v0.0.0-20250212085739-055ef3f73bd2 +replace github.com/teslamotors/vehicle-command => github.com/Lenart12/vehicle-command v0.0.0-20250212123126-a66bf814eade diff --git a/go.sum b/go.sum index 7f3cbbc..03e2ba0 100644 --- a/go.sum +++ b/go.sum @@ -1,5 +1,5 @@ -github.com/Lenart12/vehicle-command v0.0.0-20250212085739-055ef3f73bd2 h1:SB8LxlLQYDvZG/QyYS1WLoHc95V/S4wpibSGdhxHJ+E= -github.com/Lenart12/vehicle-command v0.0.0-20250212085739-055ef3f73bd2/go.mod h1:TUE4TpdW7f82y7cXEEs/RuWWDtcnVFu4Q62solTvRxA= +github.com/Lenart12/vehicle-command v0.0.0-20250212123126-a66bf814eade h1:8wuiUo5rZN44saXxAOG4AUAs88I3x+Y1c5Fi13/0TzI= +github.com/Lenart12/vehicle-command v0.0.0-20250212123126-a66bf814eade/go.mod h1:wMrK2dBSGiTsVFIZBo8T1Ca28FNZv7m3NJMeBzBBcds= github.com/akamensky/argparse v1.4.0 h1:YGzvsTqCvbEZhL8zZu2AiA5nq805NZh75JNj4ajn1xc= github.com/akamensky/argparse v1.4.0/go.mod h1:S5kwC7IuDcEr5VeXtGPRVZ5o/FdhcMlQz4IZQuw64xA= github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k= diff --git a/main.go b/main.go index f5c8492..627b304 100644 --- a/main.go +++ b/main.go @@ -33,7 +33,11 @@ func main() { } err := ble.InitAdapterWithID(btAdapterId) if err != nil { - log.Fatal("Failed to initialize Bluetooth adapter:", "err", err) + if ble.IsAdapterError(err) { + log.Fatal(ble.AdapterErrorHelpMessage(err)) + } else { + log.Fatal("Failed to initialize Bluetooth adapter:", "err", err) + } } control.SetupBleControl() From 64390c6c6d892fa03907fda7b6b2ed58a283f371 Mon Sep 17 00:00:00 2001 From: Lenart Kos Date: Thu, 13 Feb 2025 20:15:22 +0100 Subject: [PATCH 32/41] Prevent hanging on canceled requests --- internal/api/handlers/tesla.go | 33 +++++++++++++++++++++--------- internal/ble/control/control.go | 26 +++++++++++++++-------- internal/tesla/commands/command.go | 11 ++++++++++ 3 files changed, 52 insertions(+), 18 deletions(-) diff --git a/internal/api/handlers/tesla.go b/internal/api/handlers/tesla.go index 5bc5475..d2f358b 100644 --- a/internal/api/handlers/tesla.go +++ b/internal/api/handlers/tesla.go @@ -40,13 +40,13 @@ func checkBleControl(response *models.Response) bool { return true } -func processCommand(w http.ResponseWriter, r *http.Request, vin string, command_name string, src commands.CommandSourceType, body map[string]interface{}, wait bool) models.Response { +func processCommand(w http.ResponseWriter, r *http.Request, vin string, command_name string, src commands.CommandSourceType, body map[string]interface{}, wait bool) (models.Response, bool) { var response models.Response response.Vin = vin response.Command = command_name if !checkBleControl(&response) { - return response + return response, true } var apiResponse models.ApiResponse @@ -66,7 +66,17 @@ func processCommand(w http.ResponseWriter, r *http.Request, vin string, command_ wg.Add(1) control.BleControlInstance.PushCommand(command) - wg.Wait() + wgDone := make(chan struct{}) + go func() { + defer close(wgDone) + wg.Wait() + }() + select { + case <-wgDone: + case <-r.Context().Done(): + log.Info("Request cancelled", "vin", vin, "command", command_name) + return response, false + } SetCacheControl(w, config.AppConfig.CacheMaxAge) @@ -84,7 +94,7 @@ func processCommand(w http.ResponseWriter, r *http.Request, vin string, command_ response.Reason = "The command was successfully received and will be processed shortly." } - return response + return response, true } func VehicleCommand(w http.ResponseWriter, r *http.Request) { @@ -110,8 +120,9 @@ func VehicleCommand(w http.ResponseWriter, r *http.Request) { log.Info("received", "command", command, "body", body) - resp := processCommand(w, r, vin, command, commands.CommandSource.FleetVehicleCommands, body, wait) - writeResponseWithStatus(w, &resp) + if resp, ok := processCommand(w, r, vin, command, commands.CommandSource.FleetVehicleCommands, body, wait); ok { + writeResponseWithStatus(w, &resp) + } } func VehicleEndpoint(w http.ResponseWriter, r *http.Request) { @@ -170,8 +181,9 @@ func VehicleEndpoint(w http.ResponseWriter, r *http.Request) { } log.Info("received", "command", command, "body", body) - resp := processCommand(w, r, vin, command, src, body, wait) - writeResponseWithStatus(w, &resp) + if resp, ok := processCommand(w, r, vin, command, src, body, wait); ok { + writeResponseWithStatus(w, &resp) + } } func ProxyCommand(w http.ResponseWriter, r *http.Request) { @@ -190,8 +202,9 @@ func ProxyCommand(w http.ResponseWriter, r *http.Request) { } log.Info("received", "command", command) - resp := processCommand(w, r, vin, command, commands.CommandSource.TeslaBleHttpProxy, nil, true) - writeResponseWithStatus(w, &resp) + if resp, ok := processCommand(w, r, vin, command, commands.CommandSource.TeslaBleHttpProxy, nil, true); ok { + writeResponseWithStatus(w, &resp) + } } func ShowRequest(r *http.Request, handler string) { diff --git a/internal/ble/control/control.go b/internal/ble/control/control.go index 296b2c3..1e67b65 100644 --- a/internal/ble/control/control.go +++ b/internal/ble/control/control.go @@ -68,14 +68,24 @@ func (bc *BleControl) Loop() { } else { log.Debug("waiting for command") // Wait for the next command - select { - case command, ok := <-bc.providerStack: - if ok { - retryCommand = bc.connectToVehicleAndOperateConnection(&command) - } - case command, ok := <-bc.commandStack: - if ok { - retryCommand = bc.connectToVehicleAndOperateConnection(&command) + outer: + for { + select { + case command, ok := <-bc.providerStack: + if ok { + retryCommand = bc.connectToVehicleAndOperateConnection(&command) + } + break outer + case command, ok := <-bc.commandStack: + if ok { + log.Debug("command popped", "command", command.Command, "body", command.Body, "stack size", len(bc.commandStack)) + if command.IsContextDone() { + log.Debug("context done, skipping command", "command", command.Command, "body", command.Body) + continue + } + retryCommand = bc.connectToVehicleAndOperateConnection(&command) + } + break outer } } } diff --git a/internal/tesla/commands/command.go b/internal/tesla/commands/command.go index bcd44ea..6bac77f 100644 --- a/internal/tesla/commands/command.go +++ b/internal/tesla/commands/command.go @@ -77,6 +77,17 @@ func (command *Command) Domain() DomainType { } } +func (command *Command) IsContextDone() bool { + if command.Response == nil { + return false + } + if command.Response.Ctx.Err() != nil { + command.Response.Wait.Done() + return true + } + return false +} + func GetCategory(nameStr string) (vehicle.StateCategory, error) { if category, ok := categoriesByName[strings.ToLower(nameStr)]; ok { return category, nil From 19f98782289e17e7e188152f7853c8dd924ff69a Mon Sep 17 00:00:00 2001 From: Lenart Kos Date: Thu, 13 Feb 2025 20:28:34 +0100 Subject: [PATCH 33/41] Fix upstream deadlock --- go.mod | 2 +- go.sum | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/go.mod b/go.mod index b5c2de2..5228f9b 100644 --- a/go.mod +++ b/go.mod @@ -38,4 +38,4 @@ require ( ) // Replace untill /~https://github.com/teslamotors/vehicle-command/pull/373 is merged -replace github.com/teslamotors/vehicle-command => github.com/Lenart12/vehicle-command v0.0.0-20250212123126-a66bf814eade +replace github.com/teslamotors/vehicle-command => github.com/Lenart12/vehicle-command v0.0.0-20250213191938-11283fd6f653 diff --git a/go.sum b/go.sum index 03e2ba0..d294400 100644 --- a/go.sum +++ b/go.sum @@ -1,5 +1,5 @@ -github.com/Lenart12/vehicle-command v0.0.0-20250212123126-a66bf814eade h1:8wuiUo5rZN44saXxAOG4AUAs88I3x+Y1c5Fi13/0TzI= -github.com/Lenart12/vehicle-command v0.0.0-20250212123126-a66bf814eade/go.mod h1:wMrK2dBSGiTsVFIZBo8T1Ca28FNZv7m3NJMeBzBBcds= +github.com/Lenart12/vehicle-command v0.0.0-20250213191938-11283fd6f653 h1:ERfkphWXNMk4XlEkh6vXkmBJvjMtPYDUvrFC08bCQu0= +github.com/Lenart12/vehicle-command v0.0.0-20250213191938-11283fd6f653/go.mod h1:wMrK2dBSGiTsVFIZBo8T1Ca28FNZv7m3NJMeBzBBcds= github.com/akamensky/argparse v1.4.0 h1:YGzvsTqCvbEZhL8zZu2AiA5nq805NZh75JNj4ajn1xc= github.com/akamensky/argparse v1.4.0/go.mod h1:S5kwC7IuDcEr5VeXtGPRVZ5o/FdhcMlQz4IZQuw64xA= github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k= From d2f011cd0cda864a361deb7a84b9db7aa79378dc Mon Sep 17 00:00:00 2001 From: Lenart Kos Date: Thu, 13 Feb 2025 20:30:52 +0100 Subject: [PATCH 34/41] Fix method name from library API change --- internal/ble/control/control.go | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/internal/ble/control/control.go b/internal/ble/control/control.go index 1e67b65..8beb453 100644 --- a/internal/ble/control/control.go +++ b/internal/ble/control/control.go @@ -94,12 +94,14 @@ func (bc *BleControl) Loop() { func (bc *BleControl) PushCommand(command commands.Command) { bc.commandStack <- command + log.Debug("command pushed", "command", command.Command, "body", command.Body, "stack size", len(bc.commandStack)) } func processIfConnectionStatusCommand(command *commands.Command, operated bool) bool { if command.Command != "connection_status" { return false } + log.Debug("processing connection_status command", "vin", command.Vin, "operated", operated) defer func() { if command.Response.Wait != nil { @@ -312,7 +314,7 @@ func (bc *BleControl) TryConnectToVehicle(ctx context.Context, firstCommand *com log.Debug("beacon found", "localName", scanResult.LocalName, "addr", scanResult.Address.String(), "rssi", scanResult.RSSI) log.Debug("dialing to vehicle ...") - conn, err = ble.NewConnectionToBleTarget(ctx, firstCommand.Vin, scanResult) + conn, err = ble.NewConnectionFromScanResult(ctx, firstCommand.Vin, scanResult) if err != nil { return nil, nil, true, fmt.Errorf("failed to connect to vehicle (A): %s", err) } @@ -425,6 +427,7 @@ func (bc *BleControl) operateConnection(car *vehicle.Vehicle, firstCommand *comm if !ok { return nil } + log.Debug("command popped", "command", command.Command, "body", command.Body, "stack size", len(bc.commandStack)) retryCommand := handleCommand(&command) if retryCommand != nil { From 40cb54bb52be5e4967f4f48ede84c25afe34fde6 Mon Sep 17 00:00:00 2001 From: Lenart Kos Date: Thu, 13 Feb 2025 20:35:10 +0100 Subject: [PATCH 35/41] Ignore commands with canceled ctx inside operated connection --- internal/ble/control/control.go | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/internal/ble/control/control.go b/internal/ble/control/control.go index 8beb453..2e82211 100644 --- a/internal/ble/control/control.go +++ b/internal/ble/control/control.go @@ -374,6 +374,11 @@ func (bc *BleControl) operateConnection(car *vehicle.Vehicle, firstCommand *comm defer func() { bc.operatedBeacon = nil }() handleCommand := func(command *commands.Command) *commands.Command { + if command.IsContextDone() { + log.Debug("context done, skipping command", "command", command.Command, "body", command.Body) + return nil + } + if processIfConnectionStatusCommand(command, command.Vin == firstCommand.Vin) { return nil } From 82c411388296fa8fad754a1025d0d26e0e116881 Mon Sep 17 00:00:00 2001 From: Lenart Kos Date: Wed, 19 Feb 2025 21:22:36 +0100 Subject: [PATCH 36/41] Stop retry if context is canceled while retrying --- internal/ble/control/control.go | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/internal/ble/control/control.go b/internal/ble/control/control.go index 2e82211..d909fe2 100644 --- a/internal/ble/control/control.go +++ b/internal/ble/control/control.go @@ -61,11 +61,22 @@ func NewBleControl() (*BleControl, error) { func (bc *BleControl) Loop() { var retryCommand *commands.Command for { - time.Sleep(1 * time.Second) if retryCommand != nil { log.Debug("retrying command from loop", "command", retryCommand.Command, "body", retryCommand.Body) + if retryCommand.Response != nil { + select { + case <-retryCommand.Response.Ctx.Done(): + log.Debug("context done, skipping command", "command", retryCommand.Command, "body", retryCommand.Body) + retryCommand = nil + continue + case <-time.After(1 * time.Second): + } + } else { + time.Sleep(1 * time.Second) + } retryCommand = bc.connectToVehicleAndOperateConnection(retryCommand) } else { + time.Sleep(1 * time.Second) log.Debug("waiting for command") // Wait for the next command outer: From c5ca3cb3c13518cc527788961f2ff0be77a5d4cb Mon Sep 17 00:00:00 2001 From: Lenart Kos Date: Wed, 19 Feb 2025 21:26:58 +0100 Subject: [PATCH 37/41] Add retrying to connection status --- internal/ble/control/control.go | 47 +++++++++++++++++++++++++-------- 1 file changed, 36 insertions(+), 11 deletions(-) diff --git a/internal/ble/control/control.go b/internal/ble/control/control.go index d909fe2..cacdc80 100644 --- a/internal/ble/control/control.go +++ b/internal/ble/control/control.go @@ -108,22 +108,39 @@ func (bc *BleControl) PushCommand(command commands.Command) { log.Debug("command pushed", "command", command.Command, "body", command.Body, "stack size", len(bc.commandStack)) } -func processIfConnectionStatusCommand(command *commands.Command, operated bool) bool { +func processIfConnectionStatusCommand(command *commands.Command, operated bool) (accepted bool, retry bool) { if command.Command != "connection_status" { - return false + return false, false } + accepted = true + retry = true log.Debug("processing connection_status command", "vin", command.Vin, "operated", operated) defer func() { - if command.Response.Wait != nil { + if command.Response != nil && !retry { command.Response.Wait.Done() } }() + var err error + + defer func() { + if retry { + command.TotalRetries++ + if command.TotalRetries >= 3 { + log.Warn("max retries reached for connection_status") + retry = false + command.Response.Error = fmt.Sprintf("failed to get connection status after 3 retries: %v", err) + command.Response.Result = false + } + } + }() + if BleControlInstance == nil { command.Response.Error = "BleControl is not initialized. Maybe private.pem is missing." command.Response.Result = false - return true + retry = false + return } else { command.Response.Result = true } @@ -137,7 +154,6 @@ func processIfConnectionStatusCommand(command *commands.Command, operated bool) log.Warn("operated beacon is nil but operated is true") } } else { - var err error scanTimeout := config.AppConfig.ScanTimeout scanCtx, cancelScan := context.WithCancel(command.Response.Ctx) if scanTimeout > 0 { @@ -148,7 +164,7 @@ func processIfConnectionStatusCommand(command *commands.Command, operated bool) if err != nil && !strings.Contains(err.Error(), "context deadline exceeded") { command.Response.Error = err.Error() command.Response.Result = false - return true + return } } @@ -179,12 +195,17 @@ func processIfConnectionStatusCommand(command *commands.Command, operated bool) command.Response.Response = json.RawMessage(respBytes) } - return true + retry = false + return } func (bc *BleControl) connectToVehicleAndOperateConnection(firstCommand *commands.Command) *commands.Command { - if processIfConnectionStatusCommand(firstCommand, false) { - return nil + if accepted, retry := processIfConnectionStatusCommand(firstCommand, false); accepted { + if retry { + return firstCommand + } else { + return nil + } } log.Info("connecting to Vehicle ...") @@ -390,8 +411,12 @@ func (bc *BleControl) operateConnection(car *vehicle.Vehicle, firstCommand *comm return nil } - if processIfConnectionStatusCommand(command, command.Vin == firstCommand.Vin) { - return nil + if accepted, retry := processIfConnectionStatusCommand(command, command.Vin == firstCommand.Vin); accepted { + if retry { + return command + } else { + return nil + } } //If new VIN, close connection From aae890efc83cad4cbd81e2944addcb1fc8f1091b Mon Sep 17 00:00:00 2001 From: Lenart Kos Date: Wed, 19 Feb 2025 21:27:35 +0100 Subject: [PATCH 38/41] Fix scan getting stuck --- go.mod | 2 +- go.sum | 4 ++-- internal/ble/control/control.go | 3 +++ 3 files changed, 6 insertions(+), 3 deletions(-) diff --git a/go.mod b/go.mod index 5228f9b..a621e8f 100644 --- a/go.mod +++ b/go.mod @@ -38,4 +38,4 @@ require ( ) // Replace untill /~https://github.com/teslamotors/vehicle-command/pull/373 is merged -replace github.com/teslamotors/vehicle-command => github.com/Lenart12/vehicle-command v0.0.0-20250213191938-11283fd6f653 +replace github.com/teslamotors/vehicle-command => github.com/Lenart12/vehicle-command v0.0.0-20250219194759-e166ac6890e0 diff --git a/go.sum b/go.sum index d294400..a158117 100644 --- a/go.sum +++ b/go.sum @@ -1,5 +1,5 @@ -github.com/Lenart12/vehicle-command v0.0.0-20250213191938-11283fd6f653 h1:ERfkphWXNMk4XlEkh6vXkmBJvjMtPYDUvrFC08bCQu0= -github.com/Lenart12/vehicle-command v0.0.0-20250213191938-11283fd6f653/go.mod h1:wMrK2dBSGiTsVFIZBo8T1Ca28FNZv7m3NJMeBzBBcds= +github.com/Lenart12/vehicle-command v0.0.0-20250219194759-e166ac6890e0 h1:5BRUv8VYadcJazQlEC9oG2oNFE6/xrliy+JBIkRmjo4= +github.com/Lenart12/vehicle-command v0.0.0-20250219194759-e166ac6890e0/go.mod h1:wMrK2dBSGiTsVFIZBo8T1Ca28FNZv7m3NJMeBzBBcds= github.com/akamensky/argparse v1.4.0 h1:YGzvsTqCvbEZhL8zZu2AiA5nq805NZh75JNj4ajn1xc= github.com/akamensky/argparse v1.4.0/go.mod h1:S5kwC7IuDcEr5VeXtGPRVZ5o/FdhcMlQz4IZQuw64xA= github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k= diff --git a/internal/ble/control/control.go b/internal/ble/control/control.go index cacdc80..93e0868 100644 --- a/internal/ble/control/control.go +++ b/internal/ble/control/control.go @@ -338,6 +338,9 @@ func (bc *BleControl) TryConnectToVehicle(ctx context.Context, firstCommand *com if err != nil { if scanCtx.Err() != nil { return nil, nil, false, fmt.Errorf("vehicle not in range: %s", err) + } else if strings.Contains(err.Error(), "Resource Not Ready") { + // This happens if adapter is powered off, or restarting. We should retry. + return nil, nil, true, fmt.Errorf("bluetooth adapter not available") } else { return nil, nil, true, fmt.Errorf("failed to scan for vehicle: %s", err) } From 9fa1db78293eec41e74bcd9d19423400acabab9f Mon Sep 17 00:00:00 2001 From: Lenart Kos Date: Wed, 19 Feb 2025 21:33:23 +0100 Subject: [PATCH 39/41] Add some logging --- internal/ble/control/control.go | 2 ++ 1 file changed, 2 insertions(+) diff --git a/internal/ble/control/control.go b/internal/ble/control/control.go index 93e0868..7a23b89 100644 --- a/internal/ble/control/control.go +++ b/internal/ble/control/control.go @@ -126,6 +126,7 @@ func processIfConnectionStatusCommand(command *commands.Command, operated bool) defer func() { if retry { + log.Warn("retrying connection_status", "err", err) command.TotalRetries++ if command.TotalRetries >= 3 { log.Warn("max retries reached for connection_status") @@ -339,6 +340,7 @@ func (bc *BleControl) TryConnectToVehicle(ctx context.Context, firstCommand *com if scanCtx.Err() != nil { return nil, nil, false, fmt.Errorf("vehicle not in range: %s", err) } else if strings.Contains(err.Error(), "Resource Not Ready") { + log.Debug("Adapter is not ready, try powering it on") // This happens if adapter is powered off, or restarting. We should retry. return nil, nil, true, fmt.Errorf("bluetooth adapter not available") } else { From 2137d79a17ebfc7de64e5b5ab9af04e2abb472da Mon Sep 17 00:00:00 2001 From: Lenart Kos Date: Tue, 25 Feb 2025 21:46:34 +0100 Subject: [PATCH 40/41] Bump vehicle-command version --- go.mod | 6 +++--- go.sum | 12 ++++++------ 2 files changed, 9 insertions(+), 9 deletions(-) diff --git a/go.mod b/go.mod index a621e8f..c67f6ee 100644 --- a/go.mod +++ b/go.mod @@ -27,15 +27,15 @@ require ( github.com/rivo/uniseg v0.4.7 // indirect github.com/saltosystems/winrt-go v0.0.0-20240509164145-4f7860a3bd2b // indirect github.com/sirupsen/logrus v1.9.3 // indirect - github.com/soypat/cyw43439 v0.0.0-20241116210509-ae1ce0e084c5 // indirect + github.com/soypat/cyw43439 v0.0.0-20250222151126-af3e63a269de // indirect github.com/soypat/seqs v0.0.0-20240527012110-1201bab640ef // indirect github.com/stretchr/testify v1.10.0 // indirect github.com/tinygo-org/cbgo v0.0.4 // indirect github.com/tinygo-org/pio v0.0.0-20231216154340-cd888eb58899 // indirect golang.org/x/exp v0.0.0-20240808152545-0cdaa3abc0fa // indirect golang.org/x/sys v0.24.0 // indirect - tinygo.org/x/bluetooth v0.11.0 // indirect + tinygo.org/x/bluetooth v0.11.1-0.20250225202609-5befb38cd8f0 // indirect ) // Replace untill /~https://github.com/teslamotors/vehicle-command/pull/373 is merged -replace github.com/teslamotors/vehicle-command => github.com/Lenart12/vehicle-command v0.0.0-20250219194759-e166ac6890e0 +replace github.com/teslamotors/vehicle-command => github.com/Lenart12/vehicle-command v0.0.0-20250225204453-0aafe212c502 diff --git a/go.sum b/go.sum index a158117..a91c9a8 100644 --- a/go.sum +++ b/go.sum @@ -1,5 +1,5 @@ -github.com/Lenart12/vehicle-command v0.0.0-20250219194759-e166ac6890e0 h1:5BRUv8VYadcJazQlEC9oG2oNFE6/xrliy+JBIkRmjo4= -github.com/Lenart12/vehicle-command v0.0.0-20250219194759-e166ac6890e0/go.mod h1:wMrK2dBSGiTsVFIZBo8T1Ca28FNZv7m3NJMeBzBBcds= +github.com/Lenart12/vehicle-command v0.0.0-20250225204453-0aafe212c502 h1:1ZHVEe/WaCW3RjLPo7KplgaRoec1KrSeXRyhhjySAZ8= +github.com/Lenart12/vehicle-command v0.0.0-20250225204453-0aafe212c502/go.mod h1:CPEvKFeBFmFCbqVW7xT66QnNrwUvWI5cDoqsFdO5nEk= github.com/akamensky/argparse v1.4.0 h1:YGzvsTqCvbEZhL8zZu2AiA5nq805NZh75JNj4ajn1xc= github.com/akamensky/argparse v1.4.0/go.mod h1:S5kwC7IuDcEr5VeXtGPRVZ5o/FdhcMlQz4IZQuw64xA= github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k= @@ -48,8 +48,8 @@ github.com/saltosystems/winrt-go v0.0.0-20240509164145-4f7860a3bd2b/go.mod h1:CI github.com/sirupsen/logrus v1.5.0/go.mod h1:+F7Ogzej0PZc/94MaYx/nvG9jOFMD2osvC3s+Squfpo= github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ= github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ= -github.com/soypat/cyw43439 v0.0.0-20241116210509-ae1ce0e084c5 h1:arwJFX1x5zq+wUp5ADGgudhMQEXKNMQOmTh+yYgkwzw= -github.com/soypat/cyw43439 v0.0.0-20241116210509-ae1ce0e084c5/go.mod h1:1Otjk6PRhfzfcVHeWMEeku/VntFqWghUwuSQyivb2vE= +github.com/soypat/cyw43439 v0.0.0-20250222151126-af3e63a269de h1:JJgPttIUOrhi0QzUdT+rjwVrTxvVRCyQ27O6RP18FCY= +github.com/soypat/cyw43439 v0.0.0-20250222151126-af3e63a269de/go.mod h1:1Otjk6PRhfzfcVHeWMEeku/VntFqWghUwuSQyivb2vE= github.com/soypat/seqs v0.0.0-20240527012110-1201bab640ef h1:phH95I9wANjTYw6bSYLZDQfNvao+HqYDom8owbNa0P4= github.com/soypat/seqs v0.0.0-20240527012110-1201bab640ef/go.mod h1:oCVCNGCHMKoBj97Zp9znLbQ1nHxpkmOY9X+UAGzOxc8= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= @@ -75,5 +75,5 @@ gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8 gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= -tinygo.org/x/bluetooth v0.11.0 h1:32ludjNnqz6RyVRpmw2qgod7NvDePbBTWXkJm6jj4cg= -tinygo.org/x/bluetooth v0.11.0/go.mod h1:XLRopLvxWmIbofpZSXc7BGGCpgFOV5lrZ1i/DQN0BCw= +tinygo.org/x/bluetooth v0.11.1-0.20250225202609-5befb38cd8f0 h1:Cpj2QTz481uxFFwtFg8QFZYl3IyG5iBQy4QP4A+9PkU= +tinygo.org/x/bluetooth v0.11.1-0.20250225202609-5befb38cd8f0/go.mod h1:/l+WXqcrFRlM4bYzJpO9Krb9G9oLeHNmtpStN2hEV2I= From fdf944bdc4de6e375505a556c787d88da7291d16 Mon Sep 17 00:00:00 2001 From: Lenart Kos Date: Wed, 26 Feb 2025 17:14:13 +0100 Subject: [PATCH 41/41] Show when connection status command finished --- internal/ble/control/control.go | 1 + 1 file changed, 1 insertion(+) diff --git a/internal/ble/control/control.go b/internal/ble/control/control.go index 7a23b89..f4bd014 100644 --- a/internal/ble/control/control.go +++ b/internal/ble/control/control.go @@ -119,6 +119,7 @@ func processIfConnectionStatusCommand(command *commands.Command, operated bool) defer func() { if command.Response != nil && !retry { command.Response.Wait.Done() + log.Info("successfully executed", "command", command.Command, "body", command.Body) } }()