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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ All notable changes are listed here. `Unreleased` is what `main` and the Docker
### Breaking

- `/gen_keys` and `/remove_keys` accept `POST` only. `GET` now returns `405`. The dashboard already uses `POST`; scripts or bookmarks that call these endpoints with `GET` must switch. ([#170](https://github.com/wimaha/TeslaBleHttpProxy/pull/170))
- Possibly breaking for integrations: requests that fail are now always reported as failures (`result: false`, HTTP 503). Before, some of them, for example `set_charging_amps` without a body, returned `result: true` without doing anything. Commands sent without `wait=true` that cannot be queued (queue full, key change) now return 503 instead of "will be processed shortly". `body_controller_state` waits in the command queue and answers with an error after 15 seconds if the proxy is busy, for example retrying while the car is out of range. ([#174](https://github.com/wimaha/TeslaBleHttpProxy/pull/174))

### Security

Expand All @@ -17,14 +18,24 @@ All notable changes are listed here. `Unreleased` is what `main` and the Docker

- `trustedOrigins` environment variable for reverse proxies over plain HTTP that rewrite the `Host` header, and for web pages on other origins that call the API. See [environment variables](docs/environment_variables.md#trustedorigins). ([#170](https://github.com/wimaha/TeslaBleHttpProxy/pull/170))
- Docker image `:dev`, built from `main` on every push. ([#171](https://github.com/wimaha/TeslaBleHttpProxy/pull/171))
- `releaseAdapterWhenIdle` environment variable: hands the Bluetooth adapter back to the system between connections, so other Bluetooth services on the same host can use it. Off by default. Fixes [#158](https://github.com/wimaha/TeslaBleHttpProxy/issues/158), based on [#161](https://github.com/wimaha/TeslaBleHttpProxy/pull/161) by @AloisKlingler.
- `connectionTimeout` environment variable: how long a connection stays open after a command (default 29 seconds as before). Based on [#161](https://github.com/wimaha/TeslaBleHttpProxy/pull/161) by @AloisKlingler.
- README examples for `set_charge_limit` and `set_sentry_mode`. ([#169](https://github.com/wimaha/TeslaBleHttpProxy/pull/169))

### Fixed

- `set_charging_amps`, `set_charge_limit` and `set_sentry_mode` with a missing or invalid body answer with an error before the car is contacted. Before, they could report success without doing anything. Other errors that are not retried are reported too instead of coming back as success.
- `body_controller_state` goes through the command queue: it no longer opens a second Bluetooth connection next to a running command, and it is answered right away on an open connection.
- Changing or generating keys in the dashboard stops the previous command loop instead of leaving it running next to the new one. Requests during the change get a clear error instead of hanging.
- A client that disconnects while a response is written no longer stops the proxy. ([#170](https://github.com/wimaha/TeslaBleHttpProxy/pull/170))

### Changed

- `body_controller_state` waits in the command queue like every other request. If the proxy is busy with another command or retrying a connection, it answers with an error after at most 15 seconds instead of opening a second connection. A caller that gave up is not sent to the vehicle later.
- Errors are reported more strictly: requests that failed without being retried (for example a missing body) now return `result: false` (HTTP 503), where some used to return `result: true`.
- When the command queue is full (50 waiting requests, the vehicle has been unreachable for a while), new requests are answered with an error right away instead of waiting.
- Sending a key to the vehicle from the dashboard closes an idle connection of the command loop first and waits at most 30 seconds for a busy one.
- Updated BLE library ([wimaha/ble_BleConnectFix#1](https://github.com/wimaha/ble_BleConnectFix/pull/1)): when the proxy closes the adapter, it is brought back up, so BlueZ sees it again. Needed for `releaseAdapterWhenIdle`.
- Building from source requires Go 1.26 or newer. ([#172](https://github.com/wimaha/TeslaBleHttpProxy/pull/172))
- Tests run against a simulated vehicle, so commands, connection handling and the HTTP API are covered without a car. ([#169](https://github.com/wimaha/TeslaBleHttpProxy/pull/169))

Expand Down
10 changes: 10 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -239,6 +239,10 @@ This is recommended if you want to receive data frequently, since it will reduce

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:

*(unreleased)*

The request goes through the same queue as commands, so there is only ever one Bluetooth connection to the vehicle. If a connection is already open, it is answered on it right away. If the proxy is busy with another command (for example retrying while the vehicle is out of range), the request waits for at most 15 seconds and then answers with an error.

- `vehicleLockState`
- `VEHICLELOCKSTATE_UNLOCKED`
- `VEHICLELOCKSTATE_LOCKED`
Expand Down Expand Up @@ -287,6 +291,12 @@ Due to BLE's power-saving design, Tesla vehicles may terminate connections after

This is a Tesla vehicle constraint, not a limitation of TeslaBleHttpProxy.

### Sharing the Bluetooth adapter with other services

*(unreleased)*

By default the proxy keeps the Bluetooth adapter for itself while it runs, so other Bluetooth services on the same host (for example a door lock using BlueZ) report "No Bluetooth adapters found". Set [`releaseAdapterWhenIdle=true`](docs/environment_variables.md#releaseadapterwhenidle) to hand the adapter back whenever no connection to the car is open, and a lower [`connectionTimeout`](docs/environment_variables.md#connectiontimeout) such as `10` to free it sooner after a command.

### Dashboard buttons return 403 Forbidden

*(unreleased)*
Expand Down
48 changes: 36 additions & 12 deletions config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -87,12 +87,14 @@ func GetKeyFilesForRole(role string) (string, string) {
var Version = "*undefined*"

type Config struct {
LogLevel string
HttpListenAddress string
ScanTimeout int // Seconds to scan for BLE devices
CacheMaxAge int // Seconds for HTTP Cache-Control header max-age (used for body controller state responses). If set to 0, cache headers are disabled.
VehicleDataCacheTime int // Seconds to cache VehicleData endpoint responses in memory. Each endpoint is cached separately per VIN.
TrustedOrigins []string // Origins (scheme://host[:port]) allowed to send state-changing requests from a browser, e.g. a reverse proxy that rewrites the Host header.
LogLevel string
HttpListenAddress string
ScanTimeout int // Seconds to scan for BLE devices
CacheMaxAge int // Seconds for HTTP Cache-Control header max-age (used for body controller state responses). If set to 0, cache headers are disabled.
VehicleDataCacheTime int // Seconds to cache VehicleData endpoint responses in memory. Each endpoint is cached separately per VIN.
TrustedOrigins []string // Origins (scheme://host[:port]) allowed to send state-changing requests from a browser, e.g. a reverse proxy that rewrites the Host header.
ConnectionTimeout int // Seconds a BLE connection is kept open after a command for further commands.
ReleaseAdapterWhenIdle bool // Hand the BLE adapter back to the system between connections, so other Bluetooth services can use it.
}

var AppConfig *Config
Expand Down Expand Up @@ -146,6 +148,26 @@ func LoadConfig() *Config {
}
logging.Info("Env:", "vehicleDataCacheTime", vehicleDataCacheTimeInt)

connectionTimeout := os.Getenv("connectionTimeout")
if connectionTimeout == "" {
connectionTimeout = "29" // default value
}
connectionTimeoutInt, err := strconv.Atoi(connectionTimeout)
if err != nil || connectionTimeoutInt <= 0 {
logging.Error("Invalid connectionTimeout value, using default (29)", "value", connectionTimeout, "error", err)
connectionTimeoutInt = 29
}
logging.Info("Env:", "connectionTimeout", connectionTimeoutInt)

releaseAdapterWhenIdle := false
if value := os.Getenv("releaseAdapterWhenIdle"); value != "" {
if releaseAdapterWhenIdle, err = strconv.ParseBool(value); err != nil {
logging.Error("Invalid releaseAdapterWhenIdle value, using default (false)", "value", value, "error", err)
releaseAdapterWhenIdle = false
}
}
logging.Info("Env:", "releaseAdapterWhenIdle", releaseAdapterWhenIdle)

var trustedOrigins []string
for _, origin := range strings.Split(os.Getenv("trustedOrigins"), ",") {
if origin = strings.TrimSpace(origin); origin != "" {
Expand All @@ -157,12 +179,14 @@ func LoadConfig() *Config {
}

return &Config{
LogLevel: envLogLevel,
HttpListenAddress: addr,
CacheMaxAge: cacheMaxAgeInt,
ScanTimeout: scanTimeoutInt,
VehicleDataCacheTime: vehicleDataCacheTimeInt,
TrustedOrigins: trustedOrigins,
LogLevel: envLogLevel,
HttpListenAddress: addr,
CacheMaxAge: cacheMaxAgeInt,
ScanTimeout: scanTimeoutInt,
VehicleDataCacheTime: vehicleDataCacheTimeInt,
TrustedOrigins: trustedOrigins,
ConnectionTimeout: connectionTimeoutInt,
ReleaseAdapterWhenIdle: releaseAdapterWhenIdle,
}
}

Expand Down
12 changes: 12 additions & 0 deletions docs/environment_variables.md
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,18 @@ You need it in two cases, because the proxy otherwise answers `403 Forbidden`:

Case, a trailing slash and the default ports `:80`/`:443` do not matter. Several origins: `trustedOrigins=http://nas.local:8080,https://home.example.com`.

## connectionTimeout

*(unreleased)*

The number of seconds a Bluetooth connection to the vehicle stays open after a command, so that further commands (for example from evcc) are sent without connecting again. A lower value frees the Bluetooth adapter sooner, which matters with `releaseAdapterWhenIdle`; a higher value means fewer reconnects. (Default: 29)

## releaseAdapterWhenIdle

*(unreleased)*

If `true`, the proxy hands the Bluetooth adapter back to the system (BlueZ) whenever no connection to the vehicle is open, so other Bluetooth services on the same host can use it, for example a door lock. By default the proxy keeps the adapter for itself until it stops. With this option every new connection sets up the adapter again, which takes a little longer. Combine it with a lower `connectionTimeout` (for example `10`) to free the adapter sooner after a command. (Default: false)

# Example

## Docker compose
Expand Down
2 changes: 1 addition & 1 deletion go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -39,4 +39,4 @@ require (

replace github.com/teslamotors/vehicle-command => github.com/wimaha/vehicle-command v0.0.8

replace github.com/go-ble/ble => github.com/wimaha/ble_BleConnectFix v0.0.0-20240822192426-3f74826c1268
replace github.com/go-ble/ble => github.com/wimaha/ble_BleConnectFix v0.0.0-20261018210806-b933489a83fc
4 changes: 2 additions & 2 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -72,8 +72,8 @@ github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
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/ble_BleConnectFix v0.0.0-20261018210806-b933489a83fc h1:jevReITmxQ1l/Q8S7s9yP/YTiae2gyP5B3Pyg59x9QQ=
github.com/wimaha/ble_BleConnectFix v0.0.0-20261018210806-b933489a83fc/go.mod h1:fFJl/jD/uyILGBeD5iQ8tYHrPlJafyqCJzAyTHNJ1Uk=
github.com/wimaha/vehicle-command v0.0.8 h1:hFjih8U2OTI9sKrPEx3A0cKSCq/bN5pVVMeM9RSvhWw=
github.com/wimaha/vehicle-command v0.0.8/go.mod h1:liN6VG6MCc7m02wFaBm2sQT6MYGm/dJua6bG00QSpnA=
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no=
Expand Down
6 changes: 2 additions & 4 deletions internal/api/handlers/html.go
Original file line number Diff line number Diff line change
Expand Up @@ -196,8 +196,7 @@ func RemoveKeys(w http.ResponseWriter, r *http.Request) {
}
}

control.CloseBleControl()
control.SetupBleControl() // Reinitialize with new active key
control.SetupBleControl() // Reinitialize with new active key; stops the previous loop
http.Redirect(w, r, "/dashboard", http.StatusSeeOther)
}

Expand Down Expand Up @@ -319,8 +318,7 @@ func ActivateKey(w http.ResponseWriter, r *http.Request) {
Message: fmt.Sprintf("Active key changed to '%s'.", control.GetKeyRoleDisplayName(role)),
Type: models.Success,
})
// Reinitialize BLE control with new active key
control.CloseBleControl()
// Reinitialize BLE control with new active key; stops the previous loop
control.SetupBleControl()
}
}
Expand Down
83 changes: 48 additions & 35 deletions internal/api/handlers/tesla.go
Original file line number Diff line number Diff line change
Expand Up @@ -47,13 +47,15 @@ func commonDefer(w http.ResponseWriter, response *models.Response) {
logging.Debug("Response", "Command", response.Command, "Status", status, "Result", response.Result, "Reason", response.Reason)
}

func checkBleControl(response *models.Response) bool {
if control.BleControlInstance == nil {
// bleControl returns the active BleControl, read once so a key change during the request
// cannot swap it out halfway. If there is none, it sets the response reason and returns nil.
func bleControl(response *models.Response) *control.BleControl {
bc := control.Instance()
if bc == nil {
response.Reason = "BleControl is not initialized. Maybe private.pem is missing."
response.Result = false
return false
}
return true
return bc
}

func Command(w http.ResponseWriter, r *http.Request) {
Expand All @@ -72,7 +74,8 @@ func Command(w http.ResponseWriter, r *http.Request) {

defer commonDefer(w, &response)

if !checkBleControl(&response) {
bc := bleControl(&response)
if bc == nil {
return
}

Expand Down Expand Up @@ -106,7 +109,7 @@ func Command(w http.ResponseWriter, r *http.Request) {
apiResponse.Ctx = r.Context()

wg.Add(1)
control.BleControlInstance.PushCommand(command, vin, body, &apiResponse, autoWakeup)
bc.PushCommand(command, vin, body, &apiResponse, autoWakeup)

wg.Wait()

Expand All @@ -121,7 +124,12 @@ func Command(w http.ResponseWriter, r *http.Request) {
return
}

control.BleControlInstance.PushCommand(command, vin, body, nil, autoWakeup)
// Nobody waits for the outcome, but a command that was not even queued must not look accepted.
if err := bc.PushCommand(command, vin, body, nil, autoWakeup); err != nil {
response.Result = false
response.Reason = err.Error()
return
}
response.Result = true
response.Reason = "The command was successfully received and will be processed shortly."
}
Expand Down Expand Up @@ -161,7 +169,8 @@ func VehicleData(w http.ResponseWriter, r *http.Request) {

defer commonDefer(w, &response)

if !checkBleControl(&response) {
bc := bleControl(&response)
if bc == nil {
return
}

Expand Down Expand Up @@ -222,7 +231,7 @@ func VehicleData(w http.ResponseWriter, r *http.Request) {

wg.Add(1)
autoWakeup := r.URL.Query().Get("wakeup") == "true"
control.BleControlInstance.PushCommand(command, vin, map[string]interface{}{"endpoints": endpoints}, &apiResponse, autoWakeup)
bc.PushCommand(command, vin, map[string]interface{}{"endpoints": endpoints}, &apiResponse, autoWakeup)

wg.Wait()

Expand Down Expand Up @@ -302,46 +311,50 @@ func BodyControllerState(w http.ResponseWriter, r *http.Request) {

defer commonDefer(w, &response)

if !checkBleControl(&response) {
bc := bleControl(&response)
if bc == nil {
return
}

var apiResponse models.ApiResponse

// Through the queue like every other request, so it never opens a second BLE connection. If
// a connection to the car is already open, it is answered on it right away. It only needs
// VCSEC, so it does not wake the car.
ctx, cancel := context.WithTimeout(r.Context(), 15*time.Second)
apiResponse.Ctx = ctx
defer cancel()
cmd := &commands.Command{
var apiResponse models.ApiResponse
wg := sync.WaitGroup{}
apiResponse.Wait = &wg
apiResponse.Ctx = ctx
wg.Add(1)
bc.Push(commands.Command{
Command: "body-controller-state",
Domain: commands.Domain.VCSEC,
Vin: vin,
Response: &apiResponse,
}
car, _, err := control.BleControlInstance.TryConnectToVehicle(ctx, cmd)
if err == nil {
//Successful
defer car.Disconnect()
})

_, err, _ := control.BleControlInstance.ExecuteCommand(car, cmd, context.Background())
if err != nil {
response.Result = false
response.Reason = err.Error()
return
}
// The loop answers once it reaches the command; do not wait past the timeout for that.
answered := make(chan struct{})
go func() {
wg.Wait()
close(answered)
}()
select {
case <-answered:
case <-ctx.Done():
response.Result = false
response.Reason = fmt.Sprintf("failed to get body controller state: %s", ctx.Err())
return
}

if apiResponse.Result {
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
}
response.Result = true
response.Reason = "The request was successfully processed."
response.Response = apiResponse.Response
} else {
response.Result = false
response.Reason = err.Error()
response.Reason = apiResponse.Error
}
}

Expand Down
Loading
Loading