Connect your Bluetooth headphones
Learn about asynchronous shell execution and create a Bluetooth headphone toggle key.
Next ChapterWhat you’re building #
In this chapter, you’ll learn to use asynchronous shell execution to manage the connection of your Bluetooth headphones.
It takes so long to move your mouse up to the Bluetooth menu bar, poke around, find your headphones, and connect them. Right? Right?!?!
If you’re anything like me, you probably connect your headphones multiple times a day. Let’s make a hotkey to make this process quicker and mouse-free.
You’re going to take the following approach:
- Bind a hotkey to toggle your headphones
- When pressed, check if the headphones are disconnected or connected
- Depending on the result, toggle to the opposite state:
- If they are currently disconnected, we’ll connect the headphones
- If they are currently connected, we’ll disconnect the headphones
Install blueutils #
The easiest way to interact with your Bluetooth devices is via the blueutil program. Install it before moving to the next step:

brew install blueutil
Find your headphone’s device ID #
Before you can programmatically control your headphone connection, you need to find the headphone’s hardware address. This address ID is provided as an argument when connecting/disconnecting your headphones with blueutil
.
Run this command from your shell:

blueutil --recent
On my computer, this prints out a list of devices:
address: 28-f0-33-72-b9-38, not connected, not favourite, paired, name: "David’s AirPods Pro", recent access date: 2021-08-20 17:12:11 +0000
address: a4-83-e7-69-a8-9c, not connected, not favourite, not paired, name: "st-dbalatero1", recent access date: 2021-08-19 16:31:29 +0000
address: e8-81-52-2c-74-62, not connected, not favourite, not paired, name: "David’s Apple Watch", recent access date: 2021-08-19 14:54:58 +0000
address: e0-b5-5f-8c-d3-3c, not connected, not favourite, not paired, name: "-", recent access date: 2021-08-16 18:21:32 +0000
address: cc-d2-81-4b-f9-96, not connected, not favourite, not paired, name: "David’s iPhone", recent access date: 2021-08-14 14:43:38 +0000
address: ac-bc-32-97-f0-34, not connected, not favourite, not paired, name: "sorny", recent access date: 2021-08-10 02:14:33 +0000
address: 94-db-56-47-6a-86, not connected, not favourite, paired, name: "WH-1000XM4", recent access date: 2021-06-18 00:39:00 +0000
address: cc-98-8b-63-f1-2e, not connected, not favourite, not paired, name: "-", recent access date: 2020-05-18 14:27:31 +0000
address: e0-eb-40-0f-e6-b6, not connected, not favourite, not paired, name: "-", recent access date: 2020-01-04 06:48:17 +0000
address: c0-02-94-30-c0-02, not connected, not favourite, not paired, name: "Broadcom Bluetooth Device", recent access date: 2019-12-13 15:43:27 +0000
Find the device you want to control in the list, and make a note of its hardware address. The address looks like this in the output:
address: 28-f0-33-72-b9-38
In the next steps, we’ll use this address to make sure we’re connecting and disconnecting the correct device.
Create a new file #
First, make a config file to hold all your code for this project:

touch ~/.hammerspoon/headphones.lua
And require it in your main config:

require("headphones")
Detect whether your headphones are connected #
First, you’re going to bind a key to display whether your headphones are connected. Once that piece is working, you’ll wire it up to blueutil --connect
and blueutil --disconnect
to toggle the actual connection when the hotkey is pressed.
Use blueutil --is-connected <device id>
to check whether your headphones are connected (you can try this command out in your shell first). If they are connected, the program prints a 1
. If they are not connected, it prints a 0
.
We can use hs.task
to fire off the shell command to check the connection state. hs.task
is an asynchronous, non-blocking way to fire a shell command. What this means practically is that the macOS UI won’t lock up waiting for the shell command to finish, which definitely what you want in this case.
hs.task
takes a path to a binary to run, a callback function to call when the command is complete, and a Lua table of args to pass to the binary.
Create a function called checkHeadphonesConnected
that takes a single callback function (fn
) to be ran once the shell command is done:

-- FIXME: Put your headphone's device address here!
local headphoneDeviceId = "94-db-56-47-6a-86"
local blueUtil = "/usr/local/bin/blueutil"
local function checkHeadphonesConnected(fn)
hs.task.new(
blueUtil,
function(_, stdout)
-- `stdout` contains the output from our command. This will either be "0\n"
-- or "1\n".
--
-- Since the shell adds an extra newline to the output, we just want to use
-- `string.gsub` to remove it, so all we're left with is the "0" or "1" as
-- the result output.
stdout = string.gsub(stdout, "\n$", "")
-- Check if we're connected
local isConnected = stdout == "1"
-- Call our callback function with the connection result.
fn(isConnected)
end,
{
"--is-connected",
headphoneDeviceId,
}
):start()
end
Next, wire this up to a hotkey to test it out:

hs.hotkey.bind(hyper, 'b', function()
checkHeadphonesConnected(function(isConnected)
if isConnected then
hs.alert.show("Headphones are connected")
else
hs.alert.show("Headphones are NOT connected")
end
end)
end)
When you press ⌘⇧⌥⌃B, you should see an alert showing the state of your headphone connection. Try it out!
In the next steps, we’ll build on top of this function to implement connect, disconnect, and toggle.
Create disconnect and connect functions #
To toggle your connection, you need 2 functions: one for disconnect, and one for connect. Add these functions to your config file:

local function disconnectHeadphones()
hs.task.new(
blueUtil,
function()
hs.alert("Disconnected headphones")
end,
{
"--disconnect",
headphoneDeviceId,
}
):start()
end
local function connectHeadphones()
hs.task.new(
blueUtil,
function()
hs.alert("Connected headphones")
end,
{
"--connect",
headphoneDeviceId,
}
):start()
end
The shape of these functions should feel similar to the checkHeadphonesConnected
function you wrote in the previous step. All we’re doing is shelling out to blueutil --disconnect <device>
and blueutil --connect <device>
, and showing an hs.alert
after the command completes.
Add a toggle function and wire it up #
Now that we have checkHeadphonesConnected
, disconnectHeadphones
, and connectHeadphones
, just wire them together to create your toggle function:

local function toggleHeadphones()
checkHeadphonesConnected(function(isConnected)
if isConnected then
disconnectHeadphones()
else
connectHeadphones()
end
end)
end
The final step is to replace your ⌘⇧⌥⌃B hotkey binding with the toggle function:

hs.hotkey.bind(hyper, 'b', toggleHeadphones)
Paste an image to OCR text
Get the entire script #
Want to just paste in this whole project to your headphones.lua
file?

TK fill this in at the very end once we're sure all the code is solid