Bluetooth
GuidesEpisode 13

第拾参話

Bluetooth

Bluetooth LE devices, scanned for or chosen, connected to, read, written and followed; the app as a peripheral, advertising and serving; in the background; on each platform.

asuka/sdk/bluetooth talks to Bluetooth Low Energy devices: heart rate straps, scales, sensors, locks, a board of your own. It does what react-native-ble-plx and Web Bluetooth do, with each platform's own API: Core Bluetooth on iOS and macOS; Android's BluetoothLeScanner, BluetoothGatt and BluetoothGattServer; Windows.Devices.Bluetooth on Windows; BlueZ over D-Bus on Linux; Web Bluetooth in a browser that has it.

As a central, the app finds devices, connects to them, and reads, writes and subscribes to their characteristics. As a peripheral, it advertises and serves services of its own, for other devices to connect to. examples/bluetooth does both: it scans, connects to the device tapped and follows its battery and heart rate, and it can be a heart rate monitor itself.

The adapter and the permission

Go
state := bluetooth.Use() // a signal: Unknown, Unsupported, NotAsked, Denied, Off, On

go func() {
	s, err := bluetooth.Request() // asks the first time
	...
}()

Current is the state now, without asking; Request asks for the permission the first time, then reports the state; Watch and Use follow it as Bluetooth is turned on and off and the user allows the app. NotAsked means the app hasn't asked yet, Denied that the user said no: they allow it in the app's settings.

Platform What Request asks
iOS, macOS The system's prompt, with NSBluetoothAlwaysUsageDescription (asuka.json's ios.usage; the package gives a generic one)
Android The nearby devices permission (BLUETOOTH_SCAN, BLUETOOTH_CONNECT, BLUETOOTH_ADVERTISE), and the location permission for an app whose scans find where the user is (bluetooth.location, below)
Windows Access to the radio (Settings' privacy page can refuse it)
Linux Nothing: BlueZ decides. A Flatpak without access to org.bluez is Denied
The web Nothing: the browser asks for each device, in its chooser

Ask as the user does what needs it, once the app is on screen, not at launch.

Finding devices

Go
stop := bluetooth.Scan(bluetooth.ScanOptions{
	Services:   []bluetooth.UUID{bluetooth.HeartRate},
	Duplicates: true, // each advertisement, to follow the RSSI
}, func(a bluetooth.Advertisement, err error) {
	if err != nil {
		status.Set(err.Error()) // couldn't start, or stopped: off, not allowed
		return
	}
	found.Set(append(found.Peek(), a))
})

Scan runs until stop, or until the component it started in goes: a screen's scan stops with the screen. Each Advertisement has the Device (its ID and Name), the RSSI, the local name, the services, the service data, the manufacturer data by company identifier, the transmit power and whether it's connectable. Without Duplicates, a scan hears each device once. If it can't scan, or the platform stops it (Bluetooth turned off), the callback gets the error, once, and nothing after: ErrOff, ErrDenied, ErrUnsupported, and on the web ErrNotAvailable.

UUIDs are Go values: bluetooth.UUID16(0x180d), bluetooth.MustParseUUID("6e400001-b5a3-f393-e0a9-e50e24dcca9e"), and the package's names for the common services and characteristics (HeartRate, Battery, BatteryLevel, DeviceInformation...).

The system's chooser

Go
go func() {
	d, err := bluetooth.Choose(bluetooth.ChooseOptions{
		Filters:          []bluetooth.Filter{{Services: []bluetooth.UUID{bluetooth.HeartRate}}},
		OptionalServices: []bluetooth.UUID{bluetooth.Battery},
	})
	if errors.Is(err, bluetooth.ErrCanceled) {
		return
	}
	...
}()

Choose shows the system's list of nearby devices for the user to pick one: the browser's on the web (a page's only way to find one, and to be allowed it: call it as the user taps), Android's companion device dialog (which also lets the app reconnect from the background), and Windows' device picker. iOS, macOS and Linux have none for apps: ErrNotAvailable, and the app scans there.

Devices it knows

Known(ids...) finds the devices of IDs the app kept from before, to connect without scanning, and ConnectedDevices(services...) those the system is connected to already (for itself, or another app). A Device.ID is the platform's: a UUID Core Bluetooth made for the app on iOS and macOS, the address on Android, Windows and Linux, the site's own ID on the web. Keep it to find the device again on the same platform.

Connecting

Go
func DeviceScreen(d bluetooth.Device) ui.Node {
	c := bluetooth.Connect(d, bluetooth.ConnectOptions{Timeout: 20 * time.Second})
	bpm := signal.New(0)
	go func() {
		ch, err := c.Characteristic(bluetooth.HeartRate, bluetooth.HeartRateMeasurement)
		if err != nil {
			return
		}
		ui.Post(func() {
			ch.Subscribe(func(v []byte, err error) {
				if err == nil {
					bpm.Set(heartRate(v))
				}
			})
		})
	}()
	return ui.Text(signal.Func(func() string { return c.State().Get().String() }))
}

Connect returns at once with a Connection that's Connecting, then Connected; it stays until Close, the owner's disposal (going back from the screen above disconnects), or the device's going. State is a signal; Wait waits for the connection from a goroutine, Done is closed when it ends, and Err says why: a *DisconnectError with its Reason (the device ended it, it went out of range, the link failed, Bluetooth turned off, the app closed it) and the platform's code, ErrTimeout after ConnectOptions.Timeout, or what it couldn't connect with. A lost connection isn't made again: connect again, with AutoConnect to wait for the device to come back.

What waits for the device isn't on the main thread: call it from a goroutine. Each waits for the connection first, and fails with its error if it doesn't come:

  • Services discovers the services, their characteristics and their descriptors, once (again when the device says they changed); Service and Characteristic find one by UUID, or ErrNotFound.
  • A Characteristic's Read, Write (with a response) and WriteWithoutResponse (as fast as the link takes them); a Descriptor's Read and Write. A device's refusal is an *ATTRequestError: errors.Is(err, bluetooth.WriteNotPermitted).
  • MTU, and RequestMTU (Android asks the device; elsewhere the system has agreed on one: it's returned), with the longest value a write takes; RSSI; SetPriority (Android's connection priority).

Subscribe is on the main thread, as a scan is: it turns notifications (or indications, with SubscribeOptions.Indicate) on, calls back with each value, and stops with its owner, stop, or the connection. If it can't subscribe, or the connection ends, the callback gets the error.

Being a peripheral

Go
level := &bluetooth.LocalCharacteristic{
	UUID: bluetooth.BatteryLevel, Properties: bluetooth.Read | bluetooth.Notify, Permissions: bluetooth.Readable,
	OnRead: func(r bluetooth.ServerRequest) ([]byte, error) { return []byte{battery()}, nil },
}
bluetooth.Serve([]*bluetooth.LocalService{{UUID: bluetooth.Battery, Characteristics: []*bluetooth.LocalCharacteristic{level}}},
	func(err error) { status.Set(err.Error()) })
bluetooth.Advertise(bluetooth.AdvertiseOptions{LocalName: "Thermo", Services: []bluetooth.UUID{bluetooth.Battery}},
	func(err error) { status.Set(err.Error()) })
...
level.Notify([]byte{battery()}) // to the centrals subscribed

Serve serves the services until Close or the owner's disposal: a characteristic answers reads with its fixed Value, or its OnRead (the whole value: the platform reads a long one in parts); takes writes with OnWrite; and says who subscribes with OnSubscribe. An error from them is the ATT error the central gets (bluetooth.ReadNotPermitted, InvalidAttributeValueLength...). Advertise advertises until stop or the owner's disposal. Both call back with the error if the platform can't, or stops: the web has no peripheral role (ErrNotAvailable).

In the background

An app that keeps using Bluetooth with its screen away says so in asuka.json:

JSON
{
  "bluetooth": {
    "background": ["central", "peripheral"],
    "location": false
  }
}
  • "central": iOS's bluetooth-central background mode, with state restoration: iOS keeps the app's connections and its scan, and relaunches it when they have something; OnRestore gives the app what it restored, to connect again (immediate) and scan again. A scan in the background needs Services there, and hears each device once. On Android, a scan with ScanOptions.Background is one the system runs with a PendingIntent: it goes on after the app's process is gone, and what it hears then comes to OnRestore when the app runs again, until StopBackgroundScan. Android pauses a scan without services while the screen is off.
  • "peripheral": iOS's bluetooth-peripheral mode: advertising and serving go on in the background (iOS advertises less often there, the services in its overflow area).
  • "location": the app finds where the user is from its scans (beacons). By default an Android app's scans say they don't (neverForLocation), which needs no location permission but leaves some beacons out; with it, Request asks for the location permission too.

The desktops and the web use Bluetooth only while the app runs.

What the build adds

An app that imports the package is given what it needs, and no more:

Platform What
iOS NSBluetoothAlwaysUsageDescription (say yours in ios.usage); Core Bluetooth linked, for an app whose Go code calls the package; the background modes of bluetooth.background
Android BLUETOOTH_SCAN (with neverForLocation unless bluetooth.location), BLUETOOTH_CONNECT, BLUETOOTH_ADVERTISE; android.hardware.bluetooth_le and the companion device feature, not required; the background scan's receiver for "central"
macOS The purpose string, and the hardened runtime's com.apple.security.device.bluetooth entitlement
Linux The Flatpak's --system-talk-name=org.bluez

In asuka dev, the player has all of them, background modes too.

Each platform

iOS, macOS Android Windows Linux The web
Scan ✓ ✓ ✓ ✓ Only behind Chrome's experimental flag (requestLEScan); Choose
Chooser — Companion device dialog Device picker — ✓ requestDevice
Advertise Name and services only, always connectable; one advertisement (several are merged) Services, service and manufacturer data; the device's own name, not LocalName Manufacturer data; services through a served service ✓ —
GATT server ✓ ✓ ✓ ✓ —
RequestMTU The system's ✓ The system's The system's —
Connection's RSSI ✓ ✓ — While discovering —
SetPriority Nothing ✓ Windows 11's connection parameters Nothing Nothing
Disconnection reason ✓ ✓ Off or unknown BlueZ 5.82 and later Unknown
Background Modes, state restoration PendingIntent scan — — —

What else differs:

  • iOS and macOS. SubscribeOptions.Indicate is Core Bluetooth's choice, not the app's. A local characteristic's descriptors can only be UserDescription and PresentationFormat. A long write comes whole, and refused one part refuses it all. The simulator has no Bluetooth: Unsupported. A Mac app run with go run asks for its terminal.
  • Android. Asuka's Android is 13 and later, so the permissions before Android 12's aren't needed. A chooser filter matches on its first service only, and each pick makes a companion association the app keeps. Android allows 5 scan starts in 30 seconds: Asuka merges the app's scans into one and spaces its restarts.
  • Windows. The app's own advertisements can only carry manufacturer data: services are advertised by serving them. A long write to the app's server comes in parts, each at its ServerRequest.Offset. Some services (HID) are the system's.
  • Linux. BlueZ's bluetoothd on the system bus. A long write to the app's server comes in parts, as on Windows; a subscription doesn't say which central, so Notify goes to every subscriber.
  • The web. Safari and Firefox have no Web Bluetooth: Unsupported. In Chrome and Edge, a page uses only the services named in the chooser's filters and OptionalServices, and some (a blocklist) not at all. No MTU or RSSI, no peripheral role. A Device.ID is the site's, and Known finds a device of an earlier visit only where the browser has getDevices.

Testing

The fake host simulates an adapter and peripherals with their services, so an app's UI tests find, connect and read without a device:

Go
func TestHeartRate(t *testing.T) {
	var strap *fake.Peripheral
	a := uitest.Mount(t, App, uitest.Setup(func(h *fake.Host) {
		strap = &fake.Peripheral{ID: "AA:BB:CC:00:00:01", Name: "Polar H10", RSSI: -58, Advertised: []string{"180d"},
			Services: []*fake.Service{{UUID: "180d", Characteristics: []*fake.Characteristic{
				{UUID: "2a37", Properties: int(bluetooth.Notify)},
			}}}}
		h.Bluetooth().Add(strap)
	}))
	a.Tap("Scan")
	a.WaitFor("Polar H10")
	a.Tap("Polar H10")
	a.Until("subscribed", func() bool { return strap.Characteristic("180d", "2a37").Subscribed })
	strap.Notify("180d", "2a37", []byte{0, 61})
	a.WaitFor("61 bpm")
}

fake.Bluetooth also turns the adapter off (SetState), takes a device away (Remove, Disconnect), picks in the chooser (Chooser), and plays a central against the app's server (CentralRead, CentralWrite, CentralSubscribe, what it Notified).