bluetooth
BlueZ: adapter power, discovery, connected, paired and discovered devices, and pairing prompts.
list {
items = mantle.bluetooth:map(function(bluetooth)
return bluetooth.connected_devices
end),
key = function(device) return device.mac end,
each = function(device)
local battery = device.battery and string.format(" %d%%", device.battery) or ""
return rect {
on_click = function() mantle.bluetooth:disconnect(device.mac) end,
children = { text { content = device.name .. battery } },
}
end,
}
State
mantle.bluetooth:get() returns BluetoothState, nil before the first push. A field marked ? may be absent.
| Field | Type | Description |
|---|---|---|
available | boolean | BlueZ has an adapter; false without one or without bluetoothd. |
connected_devices | ConnectedDevice[] | Paired, connected devices. Unordered and may reshuffle on any push: sort before drawing. |
discoverable | boolean | Other devices can find this adapter. BlueZ turns it off after DiscoverableTimeout (180 s by default). |
discovered_devices | DiscoveredDevice[] | Unpaired devices BlueZ knows, unordered. Kept after stop_discovery; BlueZ expires unseen temporary ones after TemporaryTimeout (30 s by default). |
discovering | boolean | The adapter is scanning, whichever client started it. |
enabled | boolean | The adapter is powered. |
paired_devices | PairedDevice[] | Paired devices that are not connected. Unordered like connected_devices. |
pairing_request? | PairingRequest | The pairing question to show, or nil. Entry requests use secure_submit; confirmation, authorization and service requests use answer_pairing. |
ConnectedDevice
| Field | Type | Description |
|---|---|---|
battery? | integer | Battery percentage, or nil when the device reports none. |
busy? | DeviceAction | Same as DiscoveredDevice.busy. |
category | DeviceCategory | |
mac | string | MAC address, e.g. "00:1A:7D:DA:71:11"; every bluetooth action takes it. |
name | string | The device’s advertised name, or empty. |
DeviceAction
What the shell is doing to a device, as its busy.
One of "pairing", "connecting", "disconnecting".
DeviceCategory
A device’s kind, read from its BlueZ class of device.
| Value | Description |
|---|---|
"keyboard" | |
"mouse" | |
"headphones" | |
"headset" | |
"phone" | |
"computer" | |
"generic" | Any class not listed. |
DiscoveredDevice
| Field | Type | Description |
|---|---|---|
blocked | boolean | BlueZ refuses to pair with or connect to the device until it is unblocked. |
busy? | DeviceAction | The action this shell is running on the device, or nil; another client’s never shows. |
mac | string | MAC address, the argument of pair. |
name | string | Advertised name, often empty when the device broadcasts only an address. |
PairedDevice
| Field | Type | Description |
|---|---|---|
blocked | boolean | BlueZ refuses every connection to or from the device until it is unblocked. |
busy? | DeviceAction | Same as DiscoveredDevice.busy. |
category | DeviceCategory | |
mac | string | MAC address, the argument of connect and forget. |
name | string | The device’s advertised name, or empty. |
PairingKind
What a pairing_request asks; see PairingRequest.kind.
One of "confirm", "authorize", "service", "display", "pin_entry", "passkey_entry".
PairingRequest
What the pairing agent is asking the user.
| Field | Type | Description |
|---|---|---|
code? | string | Six-digit passkey for "confirm", passkey or PIN for "display", else nil. |
id | string | Unique for this Supervisor session. Include it with the device MAC in a secure entry target. |
kind | PairingKind | "confirm": does the device show code? "authorize": may it pair? "service": may a paired, untrusted device connect? "display": type code on the device; nothing to answer. "pin_entry" and "passkey_entry": type a secret in a Bluetooth secure_submit field. |
mac | string | The device’s MAC address. |
name | string | The device’s advertised name, or empty. |
Actions
Call each as mantle.bluetooth:<action>(arguments...); ? marks an argument you may omit.
| Action | Arguments | Description |
|---|---|---|
set_enabled | enabled: boolean | Powers the adapter on or off. |
set_discoverable | discoverable: boolean | Makes the adapter findable by other devices, or not. |
start_discovery | Clears discovered_devices and scans. The request holds, so a scan starts once the adapter powers on and pauses while a pair runs. | |
stop_discovery | Stops discovery; discovered_devices stays. | |
pair | mac: string | Pairs a discovered device, then trusts and connects it. |
connect | mac: string | Trusts and connects a paired device. |
disconnect | mac: string | Disconnects a connected device. |
forget | mac: string | Removes a device from BlueZ, unpairing it. |
answer_pairing | mac: string, accept: boolean | Accepts or rejects a confirmation, authorization or service pairing_request for mac; a yes within 750 ms of it appearing is ignored. Entry requests use secure_submit. |
Backend
BlueZ on the system bus.
| Contract | Behavior |
|---|---|
| State | org.bluez’s ObjectManager plus property changes. An adapter added later is picked up; if the tracked one disappears, another present adapter takes over. A bluetoothd that starts late or restarts is read afresh and gets the pairing agent again; what the old one reported, and its open pairing_request, are dropped. Without BlueZ, available is false, the lists stay empty and every action does nothing |
| Agent | Mantle registers the default KeyboardDisplay agent at /org/mantle/Bluez/Agent1. A confirmation, authorization, displayed code, PIN entry or passkey entry becomes pairing_request only while the adapter is discoverable or Mantle is pairing that device. A "service" request asks only for a paired device. One request shows at a time: a second is rejected, unless the first only displays a code and the second needs an answer |
| Discovery | start_discovery clears discovered_devices, but the next device change refills it with every unpaired device BlueZ still holds, earlier scans’ included; stop_discovery keeps it |
| Battery, category | Battery1 gives battery; the Class major and minor bits give category |
| Codecs | PipeWire owns them: audio.cards lists each device’s profiles, joined here by mac, each with its codec when it names one, and set_card_profile switches one |
How do I…
Pair a new device
Scan, pair a discovered device, then answer the pairing_request it raises. A "display"
request only shows a code to type on the device. Confirm, authorize and service requests take a
yes or no; a yes within 750 ms of the prompt appearing is ignored. For "pin_entry" or
"passkey_entry", submit a masked field with name = request.id .. "/" .. request.mac. The PIN
must have 1–16 ASCII letters or digits; a passkey must have 1–6 digits (0–999999). A stale id
or device is refused. Use No to decline an entry request. The typed value never enters Lua:
local function answer(accept)
return function()
local bluetooth = mantle.bluetooth:get()
local request = bluetooth and bluetooth.pairing_request
if request then
mantle.bluetooth:answer_pairing(request.mac, accept)
end
end
end
local function button(label, on_click, visible)
return rect { padding = 4, on_click = on_click, visible = visible, children = { text { content = label } } }
end
return column {
spacing = 6,
children = {
button("Scan", function() mantle.bluetooth:start_discovery() end),
list {
items = mantle.bluetooth:map(function(bluetooth)
return bluetooth.discovered_devices
end),
key = function(device) return device.mac end,
each = function(device)
return button(device.name ~= "" and device.name or device.mac, function()
mantle.bluetooth:pair(device.mac)
end)
end,
},
text {
content = mantle.bluetooth:map(function(bluetooth)
local request = bluetooth.pairing_request
if request == nil then
return ""
end
return string.format("%s (%s) %s", request.name, request.kind, request.code or "")
end, ""),
},
row {
visible = mantle.bluetooth:map(function(bluetooth)
local request = bluetooth.pairing_request
return request ~= nil and
(request.kind == "confirm" or request.kind == "authorize" or request.kind == "service")
end, false),
spacing = 6,
children = { button("Yes", answer(true)), button("No", answer(false)) },
},
textfield {
visible = mantle.bluetooth:map(function(bluetooth)
local request = bluetooth.pairing_request
return request ~= nil and (request.kind == "pin_entry" or request.kind == "passkey_entry")
end, false),
width = "fill",
height = 28,
placeholder = "PIN or passkey",
accessible_name = "Bluetooth PIN or passkey",
secure_submit = mantle.bluetooth:map(function(bluetooth)
local request = bluetooth.pairing_request
if request and (request.kind == "pin_entry" or request.kind == "passkey_entry") then
return { capability = "bluetooth", action = "pair", name = request.id .. "/" .. request.mac }
end
end),
},
button("No", answer(false), mantle.bluetooth:map(function(bluetooth)
local request = bluetooth.pairing_request
return request ~= nil and (request.kind == "pin_entry" or request.kind == "passkey_entry")
end, false)),
},
}
Gotchas
| Trap | Fix |
|---|---|
| A device pairing from its own side gets rejected | Mantle only prompts for invited devices. Set set_discoverable to true while pairing |