oriel

Bluetooth

Bluetooth LE advertising: a beacon nearby devices can see, from Zig or the page, with what each platform's stack can send.

oriel.bluetooth makes the device advertise: it sends a small Bluetooth LE beacon (service UUIDs, service data, the device’s name) that phones and computers nearby see when they scan. Apps use it to say “I’m here” before anything connects, as Quick Share’s wake-up beacon (service data under 0xFE2C) does.

The same API builds on every platform. What each platform’s Bluetooth stack can put in an advertisement differs, and capabilities() says so: a field the device can’t send is error.Unsupported, never silently dropped.

Availability

PlatformBackendService UUIDsService dataDevice nameConnectableNon-connectable
Android, ChromeOSBluetoothLeAdvertiser✓✓✓✓✓
LinuxBlueZ (org.bluez.LEAdvertisement1, system bus)✓✓✓✓✓
macOS, iOSCBPeripheralManager✓—✓✓best-effort
WindowsBluetoothLEAdvertisementPublisher✓✓——✓
  • Apple can’t advertise service data, and its advertisements are always connectable: connectable = false is accepted, nothing is published to connect to, and capabilities().non_connectable is false. So Quick Share’s 0xFE2C beacon can’t be sent from macOS or iOS; check capabilities().service_data and hide that feature there.
  • Windows rejects the device name in advertisements, and its legacy advertisements can’t be connected to.
  • One at a time on Apple, and on Android adapters without multiple-advertisement support (ChromeOS’s ARC): a second advertise while one runs is error.Failed.
  • Advertisements are legacy (31 bytes of payload). A connectable one spends 3 of them on the Flags structure, and the name counts too.

The module is the bluetooth flag, off by default. Declaring .permissions.bluetooth in addApp turns it on and writes each platform’s declarations: Android’s BLUETOOTH_ADVERTISE, BLUETOOTH_SCAN and BLUETOOTH_CONNECT (and the pre-12 BLUETOOTH/BLUETOOTH_ADMIN), iOS’s and macOS’s NSBluetoothAlwaysUsageDescription with your text. With the module on, the page sees window.oriel.features.bluetooth == true.

Quick start

// build.zig
_ = oriel.addApp(b, dep, .{
    .name = "beacon",
    .root_source_file = b.path("src/main.zig"),
    .frontend = .{ .static_dir = b.path("web") },
    .permissions = .{ .bluetooth = "Let nearby phones find this device" },
});
// Zig: ask first (Android 12+, iOS, macOS prompt), then advertise.
const bt = oriel.bluetooth;
if (bt.capabilities().service_data) {
    const h = try bt.advertise(.{ .service_data = .{ .uuid16 = 0xFE2C, .data = payload } });
    defer bt.stop(h);
    // ...
}
// The page
const caps = await oriel.bluetooth.capabilities();
oriel.listen("bluetooth:advertise", (e) => console.log(e.id, e.state, e.error));
const ad = await oriel.bluetooth.advertise({ serviceUuids16: [0x180F], localName: true });
// ...
await oriel.bluetooth.stop(ad);

Advertisements

Zig (Advertisement)PageMeaning
service_uuids16serviceUuids1616-bit service UUIDs to list
service_dataserviceData: { uuid16, data }Bytes under a 16-bit service UUID (data: a Uint8Array in the page)
local_namelocalNameInclude the system’s device name
connectableconnectableAccept connections (default false)
modemodelow_latency, balanced (default) or low_power
tx_powertxPowerhigh, medium (default), low or ultra_low

advertise returns a handle (the page gets { id }), and stop ends it. An advertisement with nothing in it is error.Unsupported.

Errors and state

advertise fails right away with:

ErrorWhen
UnsupportedNo BLE peripheral role here, or a field this device can’t send
PermissionDeniedThe bluetooth permission isn’t granted (oriel.permissions.request(.bluetooth))
AdapterOffBluetooth is off
TooLargeIt doesn’t fit the 31-byte payload
FailedThe platform refused it (or one is already running where only one can)

Most stacks answer the start later, so a returned handle isn’t a beacon in the air yet. The answer comes as the advertisement’s state: started, failed (with the error) or stopped (the platform ended it, e.g. the adapter turned off). Zig sets oriel.bluetooth.onAdvertiseState(handler); the page listens to bluetooth:advertise, { id, state, error }.

capabilities() also tells powered (the adapter is on), extended (the adapter could send more than 31 bytes) and max_bytes (what advertise sends: 31). The permission is separate, oriel.permissions.status(.bluetooth): on macOS and iOS, while it’s undecided, powered is false (asking the adapter would show the prompt). oriel check prints them.

Ask first. Call oriel.permissions.request(.bluetooth) (the page: oriel.permissions.request("bluetooth")) before advertising. On macOS and iOS an advertise while the permission is undecided shows the prompt and waits for the answer: its state comes once the user decides (started, or failed with PermissionDenied), however long that takes.

Related

Search documentation

Type to search the documentation.