From fc7cf157a7af2db3661a07ef976547ccd6c1ad36 Mon Sep 17 00:00:00 2001 From: Lenart Kos Date: Tue, 8 Apr 2025 22:33:36 +0200 Subject: [PATCH] Add option to select hci adapter --- cmd/tesla-control/main.go | 11 ++- examples/ble/doc.go | 10 +++ examples/ble/main.go | 66 +++++++++++++++-- pkg/cli/config.go | 7 ++ pkg/cli/config_darwin.go | 5 ++ pkg/cli/config_linux.go | 9 +++ pkg/cli/config_windows.go | 5 ++ pkg/connector/ble/ble.go | 107 +++++++++++++++++++++------- pkg/connector/ble/device_darwin.go | 16 ++++- pkg/connector/ble/device_linux.go | 39 +++++++++- pkg/connector/ble/device_windows.go | 12 +++- 11 files changed, 245 insertions(+), 42 deletions(-) create mode 100644 pkg/cli/config_darwin.go create mode 100644 pkg/cli/config_linux.go create mode 100644 pkg/cli/config_windows.go diff --git a/cmd/tesla-control/main.go b/cmd/tesla-control/main.go index 368254d4..046ad6e8 100644 --- a/cmd/tesla-control/main.go +++ b/cmd/tesla-control/main.go @@ -16,6 +16,7 @@ import ( "github.com/teslamotors/vehicle-command/internal/log" "github.com/teslamotors/vehicle-command/pkg/account" "github.com/teslamotors/vehicle-command/pkg/cli" + "github.com/teslamotors/vehicle-command/pkg/connector/ble" "github.com/teslamotors/vehicle-command/pkg/protocol" "github.com/teslamotors/vehicle-command/pkg/vehicle" ) @@ -163,12 +164,10 @@ func main() { acct, car, err := config.Connect(ctx) if err != nil { - writeErr("Error: %s", err) - // Error isn't wrapped so we have to check for a substring explicitly. - 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. - writeErr("\nTry again after granting this application CAP_NET_ADMIN:\n\n\tsudo setcap 'cap_net_admin=eip' \"$(which %s)\"\n", os.Args[0]) + if ble.IsAdapterError(err) { + writeErr("%s", ble.AdapterErrorHelpMessage(err)) + } else { + writeErr("Error: %s", err) } return } diff --git a/examples/ble/doc.go b/examples/ble/doc.go index e99921fe..ad3cc7f2 100644 --- a/examples/ble/doc.go +++ b/examples/ble/doc.go @@ -5,6 +5,12 @@ your car and turns on the AC. For more fleshed out examples of other commands, see [github.com/tesla/vehicle-command/pkg/cmd/tesla-control]. +# Scanning for vehicles + +To scan for vehicles, use the -scan-only flag: + + ./ble -scan-only -vin YOUR_VIN + # Pairing with the vehicle To generate a key pair with OpenSSL: @@ -26,5 +32,9 @@ Sending commands to the vehicle requires the private key you generated above: ./ble -vin YOUR_VIN -key private.pem You can add the -debug flag to inspect the bytes sent over BLE. + +You can also specify the Bluetooth adapter to use on Linux with the -bt-adapter flag: + + ./ble -vin YOUR_VIN -key private.pem -bt-adapter hci0 */ package main diff --git a/examples/ble/main.go b/examples/ble/main.go index 59a91b23..60466445 100644 --- a/examples/ble/main.go +++ b/examples/ble/main.go @@ -8,6 +8,8 @@ import ( "fmt" "log" "os" + "os/signal" + "runtime" "time" debugger "github.com/teslamotors/vehicle-command/internal/log" @@ -27,29 +29,74 @@ func main() { // Provided through command line options var ( + scanOnly bool + btAdapter string privateKeyFile string vin string ) + flag.BoolVar(&scanOnly, "scan-only", false, "Scan for vehicles and exit") flag.StringVar(&privateKeyFile, "key", "", "Private key `file` for authorizing commands (PEM PKCS8 NIST-P256)") flag.StringVar(&vin, "vin", "", "Vehicle Identification Number (`VIN`) of the car") flag.BoolVar(&debug, "debug", false, "Enable debugging of TX/RX BLE packets") + if runtime.GOOS == "linux" { + flag.StringVar(&btAdapter, "bt-adapter", "", "Optional ID of Bluetooth adapter to use") + } + flag.Parse() if debug { debugger.SetLevel(debugger.LevelDebug) } - // For simplcity, allow 30 seconds to wake up the vehicle, connect to it, - // and unlock. In practice you'd want a fresh timeout for each command. - ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) - defer cancel() + err := ble.InitAdapterWithID(btAdapter) + if err != nil { + if ble.IsAdapterError(err) { + logger.Print(ble.AdapterErrorHelpMessage(err)) + } else { + logger.Printf("Failed to initialize BLE adapter: %s", err) + } + return + } if vin == "" { logger.Printf("Must specify VIN") return } - var err error + if scanOnly { + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + doneChan := make(chan struct{}) + go func() { + _, err := ble.ScanVehicleBeacon(ctx, vin) + if err != nil && ctx.Err() == nil { + logger.Printf("Scan failed: %s", err) + } else if ctx.Err() == nil { + logger.Printf("Found vehicle") + status = 0 + } + close(doneChan) + }() + logger.Printf("Scanning for BLE devices until interrupted") + + signalChan := make(chan os.Signal, 1) + signal.Notify(signalChan, os.Interrupt) + select { + case <-doneChan: + case <-signalChan: + logger.Printf("Stopping scan") + cancel() + <-doneChan + status = 130 // Script terminated by SIGINT + } + return + } + + // For simplicity, allow 30 seconds to wake up the vehicle, connect to it, + // and unlock. In practice you'd want a fresh timeout for each command. + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + var privateKey protocol.ECDHPrivateKey if privateKeyFile != "" { if privateKey, err = protocol.LoadPrivateKey(privateKeyFile); err != nil { @@ -58,7 +105,14 @@ func main() { } } - conn, err := ble.NewConnection(ctx, vin) + scan, err := ble.ScanVehicleBeacon(ctx, vin) + if err != nil { + logger.Println(err) + return + } + logger.Printf("Found vehicle: %s (%s) %ddBm", scan.LocalName, scan.Address, scan.RSSI) + + conn, err := ble.NewConnectionFromScanResult(ctx, vin, scan) if err != nil { logger.Printf("Failed to connect to vehicle: %s", err) return diff --git a/pkg/cli/config.go b/pkg/cli/config.go index f67c7d62..1b22f647 100644 --- a/pkg/cli/config.go +++ b/pkg/cli/config.go @@ -144,6 +144,7 @@ type Config struct { KeyringKeyName string // Username for private key in system keyring KeyringTokenName string // Username for OAuth token in system keyring VIN string + BtAdapterID string // ID of Bluetooth adapter to use (Linux only) TokenFilename string KeyFilename string CacheFilename string @@ -207,6 +208,7 @@ func (c *Config) RegisterCommandLineFlags() { flag.StringVar(&c.Backend.FileDir, "keyring-file-dir", keyringDirectory, "keyring `directory` for file-backed keyring types") flag.BoolVar(&c.Debug, "keyring-debug", false, "Enable keyring debug logging") } + c.registerCommandLineFlagsOsSpecific() } // LoadCredentials attempts to open a keyring, prompting for a password if not needed. Call this @@ -469,6 +471,11 @@ func (c *Config) ConnectRemote(ctx context.Context, skey protocol.ECDHPrivateKey // ConnectLocal connects to a vehicle over BLE. func (c *Config) ConnectLocal(ctx context.Context, skey protocol.ECDHPrivateKey) (car *vehicle.Vehicle, err error) { + err = ble.InitAdapterWithID(c.BtAdapterID) + if err != nil { + return nil, err + } + conn, err := ble.NewConnection(ctx, c.VIN) if err != nil { return nil, err diff --git a/pkg/cli/config_darwin.go b/pkg/cli/config_darwin.go new file mode 100644 index 00000000..fe0c95c1 --- /dev/null +++ b/pkg/cli/config_darwin.go @@ -0,0 +1,5 @@ +package cli + +func (c *Config) registerCommandLineFlagsOsSpecific() { + // Nothing yet +} diff --git a/pkg/cli/config_linux.go b/pkg/cli/config_linux.go new file mode 100644 index 00000000..c1b786e3 --- /dev/null +++ b/pkg/cli/config_linux.go @@ -0,0 +1,9 @@ +package cli + +import "flag" + +func (c *Config) registerCommandLineFlagsOsSpecific() { + if c.Flags.isSet(FlagBLE) { + flag.StringVar(&c.BtAdapterID, "bt-adapter", "", "ID of the Bluetooth adapter to use. Defaults to hci0.") + } +} diff --git a/pkg/cli/config_windows.go b/pkg/cli/config_windows.go new file mode 100644 index 00000000..fe0c95c1 --- /dev/null +++ b/pkg/cli/config_windows.go @@ -0,0 +1,5 @@ +package cli + +func (c *Config) registerCommandLineFlagsOsSpecific() { + // Nothing yet +} diff --git a/pkg/connector/ble/ble.go b/pkg/connector/ble/ble.go index 2de89706..8d94575f 100644 --- a/pkg/connector/ble/ble.go +++ b/pkg/connector/ble/ble.go @@ -7,7 +7,6 @@ import ( "crypto/sha1" "errors" "fmt" - "strings" "sync" "time" @@ -17,8 +16,12 @@ import ( "github.com/teslamotors/vehicle-command/pkg/protocol" ) -const maxBLEMessageSize = 1024 +const ( + maxBLEMTUSize = ble.MaxMTU // Max MTU size accepted by the client (this library) + maxBLEMessageSize = 1024 +) +var ErrAdapterInvalidID = protocol.NewError("the bluetooth adapter ID is invalid", false, false) var ErrMaxConnectionsExceeded = protocol.NewError("the vehicle is already connected to the maximum number of BLE devices", false, false) var ( @@ -41,6 +44,7 @@ type Connection struct { vin string inbox chan []byte txChar *ble.Characteristic + blockLength int rxChar *ble.Characteristic inputBuffer []byte client ble.Client @@ -109,7 +113,7 @@ func (c *Connection) Send(_ context.Context, buffer []byte) error { log.Debug("TX: %02x", buffer) out = append(out, uint8(len(buffer)>>8), uint8(len(buffer))) out = append(out, buffer...) - blockLength := 20 + blockLength := c.blockLength for len(out) > 0 { if blockLength > len(out) { blockLength = len(out) @@ -132,7 +136,35 @@ func VehicleLocalName(vin string) string { return fmt.Sprintf("S%02xC", digest[:8]) } -func initDevice() error { +// InitAdapterWithID initializes the BLE adapter with the given ID. +// Currently this is only supported on Linux. It is not necessary to +// call this function if using the default adapter, but if not, it +// must be called before making any other BLE calls. +// Linux: +// - id is in the form "hciX" where X is the number of the adapter. +func InitAdapterWithID(id string) error { + mu.Lock() + defer mu.Unlock() + return initAdapter(&id) +} + +// CloseAdapter unsets the BLE adapter so that a new one can be created +// on the next call to InitAdapter. This does not disconnect any existing +// connections or stop any ongoing scans and must be done separately. +func CloseAdapter() error { + mu.Lock() + defer mu.Unlock() + if device != nil { + if err := device.Stop(); err != nil { + return fmt.Errorf("ble: failed to stop device: %s", err) + } + device = nil + log.Debug("Closed BLE adapter") + } + return nil +} + +func initAdapter(id *string) error { var err error // We don't want concurrent calls to NewConnection that would defeat // the point of reusing the existing BLE device. Note that this is not @@ -140,23 +172,36 @@ func initDevice() error { if device != nil { log.Debug("Reusing existing BLE device") } else { - log.Debug("Creating new BLE device") - device, err = newDevice() + log.Debug("Creating new BLE adapter") + device, err = newAdapter(id) if err != nil { - return fmt.Errorf("failed to find a BLE device: %s", err) + return fmt.Errorf("ble: failed to enable device: %s", err) } - ble.SetDefaultDevice(device) } return nil } -type Advertisement = ble.Advertisement +type ScanResult struct { + Address string + LocalName string + RSSI int16 + Connectable bool +} + +func advertisementToScanResult(a ble.Advertisement) *ScanResult { + return &ScanResult{ + Address: a.Addr().String(), + LocalName: a.LocalName(), + RSSI: int16(a.RSSI()), + Connectable: a.Connectable(), + } +} -func ScanVehicleBeacon(ctx context.Context, vin string) (Advertisement, error) { +func ScanVehicleBeacon(ctx context.Context, vin string) (*ScanResult, error) { mu.Lock() defer mu.Unlock() - if err := initDevice(); err != nil { + if err := initAdapter(nil); err != nil { return nil, err } @@ -167,13 +212,13 @@ func ScanVehicleBeacon(ctx context.Context, vin string) (Advertisement, error) { return a, nil } -func scanVehicleBeacon(ctx context.Context, localName string) (Advertisement, error) { +func scanVehicleBeacon(ctx context.Context, localName string) (*ScanResult, error) { var err error ctx2, cancel := context.WithCancel(ctx) defer cancel() - ch := make(chan Advertisement, 1) - fn := func(a Advertisement) { + ch := make(chan ble.Advertisement, 1) + fn := func(a ble.Advertisement) { if a.LocalName() != localName { return } @@ -199,24 +244,26 @@ func scanVehicleBeacon(ctx context.Context, localName string) (Advertisement, er // This should never happen, but just in case return nil, fmt.Errorf("scan channel closed") } - return a, nil + return advertisementToScanResult(a), nil case <-ctx.Done(): return nil, ctx.Err() } } func NewConnection(ctx context.Context, vin string) (*Connection, error) { - return NewConnectionToBleTarget(ctx, vin, nil) + return NewConnectionFromScanResult(ctx, vin, nil) } -func NewConnectionToBleTarget(ctx context.Context, vin string, target Advertisement) (*Connection, error) { +// NewConnectionFromScanResult creates a new BLE connection to the given target. +// If target is nil, the vehicle will be scanned for. +func NewConnectionFromScanResult(ctx context.Context, vin string, target *ScanResult) (*Connection, error) { var lastError error for { conn, retry, err := tryToConnect(ctx, vin, target) if err == nil { return conn, nil } - if !retry || strings.Contains(err.Error(), "operation not permitted") { + if !retry || IsAdapterError(err) { return nil, err } log.Warning("BLE connection attempt failed: %s", err) @@ -230,12 +277,12 @@ func NewConnectionToBleTarget(ctx context.Context, vin string, target Advertisem } } -func tryToConnect(ctx context.Context, vin string, target Advertisement) (*Connection, bool, error) { +func tryToConnect(ctx context.Context, vin string, target *ScanResult) (*Connection, bool, error) { var err error mu.Lock() defer mu.Unlock() - if err = initDevice(); err != nil { + if err = initAdapter(nil); err != nil { return nil, false, err } @@ -248,17 +295,17 @@ func tryToConnect(ctx context.Context, vin string, target Advertisement) (*Conne } } - if target.LocalName() != localName { - return nil, false, fmt.Errorf("ble: beacon with unexpected local name: '%s'", target.LocalName()) + if target.LocalName != localName { + return nil, false, fmt.Errorf("ble: beacon with unexpected local name: '%s'", target.LocalName) } - if !target.Connectable() { + if !target.Connectable { return nil, false, ErrMaxConnectionsExceeded } - log.Debug("Dialing to %s (%s)...", target.Addr(), localName) + log.Debug("Dialing to %s (%s)...", target.Address, localName) - client, err := ble.Dial(ctx, target.Addr()) + client, err := device.Dial(ctx, ble.NewAddr(target.Address)) if err != nil { return nil, true, fmt.Errorf("ble: failed to dial for %s (%s): %s", vin, localName, err) } @@ -298,6 +345,16 @@ func tryToConnect(ctx context.Context, vin string, target Advertisement) (*Conne if err := client.Subscribe(conn.rxChar, true, conn.rx); err != nil { return nil, true, fmt.Errorf("ble: failed to subscribe to RX: %s", err) } + + txMtu, err := client.ExchangeMTU(maxBLEMTUSize) + if err != nil { + log.Warning("ble: failed to exchange MTU: %s", err) + conn.blockLength = ble.DefaultMTU - 3 // Fallback to default MTU size + } else { + conn.blockLength = min(txMtu, maxBLEMessageSize) - 3 // 3 bytes for header + log.Debug("MTU size: %d", txMtu) + } + log.Info("Connected to vehicle BLE") return &conn, false, nil } diff --git a/pkg/connector/ble/device_darwin.go b/pkg/connector/ble/device_darwin.go index 6b14f0e9..53fdedba 100644 --- a/pkg/connector/ble/device_darwin.go +++ b/pkg/connector/ble/device_darwin.go @@ -3,9 +3,23 @@ package ble import ( "github.com/go-ble/ble" "github.com/go-ble/ble/darwin" + "github.com/teslamotors/vehicle-command/internal/log" ) -func newDevice() (ble.Device, error) { +func IsAdapterError(_ error) bool { + // TODO: Add check for Darwin + return false +} + +func AdapterErrorHelpMessage(err error) string { + return err.Error() +} + +func newAdapter(id *string) (ble.Device, error) { + if id != nil && *id != "" { + log.Warning("Darwin does not support specifying a Bluetooth adapter ID") + return nil, ErrAdapterInvalidID + } device, err := darwin.NewDevice() if err != nil { return nil, err diff --git a/pkg/connector/ble/device_linux.go b/pkg/connector/ble/device_linux.go index 3798266f..61cd508a 100644 --- a/pkg/connector/ble/device_linux.go +++ b/pkg/connector/ble/device_linux.go @@ -1,12 +1,28 @@ package ble import ( + "os" + "strconv" + "strings" + "time" + "github.com/go-ble/ble" "github.com/go-ble/ble/linux" "github.com/go-ble/ble/linux/hci/cmd" - "time" ) +func IsAdapterError(err error) bool { + return strings.Contains(err.Error(), "operation not permitted") +} + +func AdapterErrorHelpMessage(err error) string { + // 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 "Failed to initialize BLE adapter: \n\t" + err.Error() + "\n" + + "Try again after granting this application CAP_NET_ADMIN or running with root:\n\n" + + "\tsudo setcap 'cap_net_admin=eip' \"$(which " + os.Args[0] + ")\"" +} + const bleTimeout = 20 * time.Second // TODO: Depending on the model and state, BLE advertisements come every 20ms or every 150ms. @@ -19,8 +35,25 @@ var scanParams = cmd.LESetScanParameters{ ScanningFilterPolicy: 2, // Basic filtered } -func newDevice() (ble.Device, error) { - device, err := linux.NewDevice(ble.OptListenerTimeout(bleTimeout), ble.OptDialerTimeout(bleTimeout), ble.OptScanParams(scanParams)) +func newAdapter(id *string) (ble.Device, error) { + opts := []ble.Option{ + ble.OptDialerTimeout(bleTimeout), + ble.OptListenerTimeout(bleTimeout), + ble.OptScanParams(scanParams), + } + if id != nil && *id != "" { + if !strings.HasPrefix(*id, "hci") { + return nil, ErrAdapterInvalidID + } + hciStr := strings.TrimPrefix(*id, "hci") + hciID, err := strconv.Atoi(hciStr) + if err != nil || hciID < 0 || hciID > 15 { + return nil, ErrAdapterInvalidID + } + opts = append(opts, ble.OptDeviceID(hciID)) + } + + device, err := linux.NewDeviceWithName("vehicle-command", opts...) if err != nil { return nil, err } diff --git a/pkg/connector/ble/device_windows.go b/pkg/connector/ble/device_windows.go index 52493141..ad1d1ec5 100644 --- a/pkg/connector/ble/device_windows.go +++ b/pkg/connector/ble/device_windows.go @@ -2,9 +2,19 @@ package ble import ( "errors" + "github.com/go-ble/ble" ) -func newDevice() (ble.Device, error) { +func IsAdapterError(_ error) bool { + // TODO: Add check for Windows + return false +} + +func AdapterErrorHelpMessage(err error) string { + return err.Error() +} + +func newAdapter(_ *string) (ble.Device, error) { return nil, errors.New("not supported on Windows") }