Back to blog
Technical post

Ruby, Linux and Bluetooth

Ruby talks to Bluetooth devices on Linux through D-Bus and BlueZ. This post shows how to pair a device, discover its GATT services, and subscribe to real-time battery notifications from a plain Ruby script.

Ruby, Linux and Bluetooth

Linux exposes Bluetooth devices through BlueZ, its official Bluetooth stack, over D-Bus. That means any language with a D-Bus binding can talk to your Bluetooth devices directly, no special framework needed. Ruby has ruby-dbus, and that is all you need.

The setup requires two system packages and one gem.

# Install BlueZ (Linux Bluetooth stack)
sudo apt install -y bluez bluez-tools

# Install Ruby gem for D-Bus
gem install ruby-dbus

Before writing any Ruby, you need to pair the device and find its GATT characteristic paths. BlueZ ships with bluetoothctl, an interactive shell that handles all of this.

# Open bluetoothctl
bluetoothctl

# Inside bluetoothctl
power on
scan le
# wait for device to appear:
# [NEW] Device E7:9D:09:EF:F5:8F JBL Tune 520BT-LE
scan off

# Connect and wait for services to resolve
connect E7:9D:09:EF:F5:8F
# wait for:
# [CHG] Device E7:9D:09:EF:F5:8F ServicesResolved: yes

# Discover GATT paths
menu gatt
list-attributes E7:9D:09:EF:F5:8F
# look for:
# Battery Level  -> .../service0033/char0034
# Manufacturer   -> .../service0037/char0038
# Model Number   -> .../service0037/char003a

Once services resolve, list-attributes dumps every GATT characteristic the device exposes. The paths you care about are the ones mapped to Battery Level, Manufacturer Name, and Model Number. Those paths go directly into the Ruby script.

The script connects to the system D-Bus, reads the static device info once by polling, then subscribes to the PropertiesChanged signal on the battery characteristic so it receives updates without polling at all.

require 'dbus'

BLUEZ_SERVICE        = "org.bluez"
DEVICE_PATH          = "/org/bluez/hci0/dev_E7_9D_09_EF_F5_8F"
BATTERY_CHAR_PATH    = "#{DEVICE_PATH}/service0033/char0034"
MANUFACTURER_PATH    = "#{DEVICE_PATH}/service0037/char0038"
MODEL_PATH           = "#{DEVICE_PATH}/service0037/char003a"

GATT_INTERFACE       = "org.bluez.GattCharacteristic1"
PROPERTIES_INTERFACE = "org.freedesktop.DBus.Properties"

def read_characteristic(bluez, path)
  obj = bluez.object(path)
  obj.introspect
  obj[GATT_INTERFACE].ReadValue({}).first
end

def bytes_to_string(bytes)
  bytes.map(&:chr).join.strip
end

# Connect to system D-Bus
bus   = DBus::SystemBus.instance
bluez = bus.service(BLUEZ_SERVICE)

# Read static device info (polling - these never change)
manufacturer = bytes_to_string(read_characteristic(bluez, MANUFACTURER_PATH))
model        = bytes_to_string(read_characteristic(bluez, MODEL_PATH))

puts "Device : #{manufacturer} #{model}"

# Setup battery characteristic for notifications
battery_obj = bluez.object(BATTERY_CHAR_PATH)
battery_obj.introspect

battery_gatt  = battery_obj[GATT_INTERFACE]
battery_props = battery_obj[PROPERTIES_INTERFACE]

# Subscribe to PropertiesChanged signal
# BlueZ emits this signal whenever the characteristic value changes
battery_props.on_signal("PropertiesChanged") do |iface, changed, _|
  next unless iface == GATT_INTERFACE
  next unless changed["Value"]

  battery = changed["Value"].first
  puts "[#{Time.now.strftime('%H:%M:%S')}] Battery: #{battery}%"
end

# Tell the device to start sending notifications
battery_gatt.StartNotify

puts "Listening for battery notifications... (Ctrl+C to stop)"
puts

# D-Bus event loop - blocks here and fires the signal handler above
main_loop = DBus::Main.new
main_loop << bus
main_loop.run

A few things worth noting. ReadValue returns an array of bytes, so bytes_to_string maps each byte to its ASCII character and joins them. The on_signal block receives every PropertiesChanged event on that object, so the two next unless guards filter out unrelated interfaces and signals that carry no Value key. StartNotify tells the device to begin pushing updates, and the D-Bus main loop sits at the bottom blocking the process and dispatching incoming signals to the registered handlers.

The result is a script that prints a timestamped battery percentage every time the headphones report a change, with no polling loop and no external process to manage.

The same pattern applies to any GATT characteristic your device exposes. Swap the path, adjust the byte parsing, and you have a live stream of whatever data the device is broadcasting.