Bluetooth technology has become an integral part of our daily lives, connecting everything from headphones to smart home devices. But have you ever wondered how you could harness this technology within your Python projects?
Today, you’ll learn how to build a Bluetooth device scanner in Python. This is where the adventure begins as we crack down the seal that stores the data, thanks to the BleakScanner and asyncio libraries, while keeping things user-friendly with tkinter for the GUI. By the end of this tutorial, you’ll have a fully functional Bluetooth device scanner at your fingertips.
Table of Contents
- Necessary Libraries
- Imports
- Starting Bluetooth Scanning
- Handling Device Information: Display, Log, and Filter Functions
- Granting Bluetooth Permissions on Linux/macOS
- Starting the Scan and Running Asyncio in a Thread
- Building the Main Window
- Example
- Full Code
Necessary Libraries
Be sure to install these two libraries so everything runs smoothly:
$ pip install bleak
$ pip install tk
Imports
import asyncio
import threading
import tkinter as tk
from tkinter import scrolledtext
from bleak import BleakScanner
from bleak.exc import BleakError
import datetime
import os
import sys
Just like any adventure, we need to prepare our trusty tools before diving in. Here’s a quick rundown of what we’ll be using:
asyncio
: Manages multiple tasks concurrently without blocking the program.threading
: Runs tasks in the background simultaneously.tkinter
: Creates our graphical user interface and a scrollable text widget viascrolledtext
.BleakScanner
: Handles Bluetooth scanning using the Bleak library.BleakError
: Manages errors related to Bluetooth scanning.datetime
: Deals with time-related functions and timestamps.os
: Interacts with the operating system.sys
: Provides access to system-specific parameters and functions.
With these tools in place, we’re ready to get started!
Starting Bluetooth Scanning
Now that our tools are ready, let’s explore the core of our script: the start_scan()
function:
Preparation and Setup
We start by declaring it as asynchronous to allow Bluetooth scanning to run smoothly without blocking other tasks or risking a frozen GUI.
# Function to start scanning for Bluetooth devices
async def start_scan(target_name, target_address, display_box):
display_box.insert(tk.END, "Checking Bluetooth adapter status...\n")
display_box.see(tk.END)
Checking Bluetooth Permissions
The function first informs the user that it’s checking the Bluetooth adapter status by displaying a message on the display_box
. It then takes target_name
and target_address
as search criteria. Next, it checks if the operating system is Linux or Darwin using sys.platform()
, and calls the check_bluetooth_permissions()
function to ensure the appropriate permissions are granted.
# Check Bluetooth permissions and adapter status on Linux/macOS
if sys.platform == "linux" or sys.platform == "darwin":
check_bluetooth_permissions(display_box)
Scanning for Devices
Once permissions are obtained, start_scan()
uses BleakScanner.discover()
to scan for Bluetooth devices for 10 seconds and displays the results on the display_box
.
try:
# Increase the scanning timeout to give the adapter more time to detect devices
devices = await BleakScanner.discover(timeout=10) # Scanning for 10 seconds
display_box.insert(tk.END, "Scanning for Bluetooth devices...\n")
display_box.see(tk.END)
Logging and Displaying Devices
For each found device, it logs the device using log_device()
and displays it using the display_device()
function.
if devices:
for device in devices:
log_device(device) # Log all devices detected
display_device(device, display_box) # Show all devices in display box
Filtering and Matching Devices
The function then verifies if any of the found devices match the search criteria with the matches_filter()
function. If there is a match, it informs the user by displaying it as a matched device.
# Check if it matches filter criteria
if matches_filter(device, target_name, target_address):
display_box.insert(tk.END, f"Matched Device: {device.name or 'Unknown'}, Address: {device.address}\n")
display_box.see(tk.END)
Handling Errors
Finally, if any errors occur during this process, they are displayed on the display_box
.
else:
display_box.insert(tk.END, "No devices found.\n")
display_box.see(tk.END)
display_box.insert(tk.END, "Scan complete.\n")
display_box.see(tk.END)
except BleakError as e:
display_box.insert(tk.END, f"Bluetooth error: {e}\n")
display_box.see(tk.END)
except Exception as e:
display_box.insert(tk.END, f"Unexpected error: {e}\n")
display_box.see(tk.END)
Handling Device Information: Display, Log, and Filter Functions
We’ve previously mentioned some helper functions, but we haven’t discussed their specific roles. Let’s address that now:
Displaying Discovered Devices
The display_device()
function takes a discovered Bluetooth device and displays its name (if available), address, and RSSI (signal strength) on the display_box
.
# Function to display the discovered devices in the text box
def display_device(device, display_box):
device_name = device.name or "Unknown"
display_box.insert(tk.END, f"Found Device - Name: {device_name}, Address: {device.address}, RSSI: {device.rssi} dBm\n")
display_box.see(tk.END)
Logging Discovered Devices
The log_device()
function logs the timestamp, name (if available), and address of each discovered device to a file named bluetooth_scan_log.txt
. It creates the file if it doesn’t exist and appends each new log entry.
# Function to log the discovered devices to a file
def log_device(device):
device_name = device.name or "Unknown"
with open("bluetooth_scan_log.txt", "a") as log_file:
log_entry = f"{datetime.datetime.now()} - Name: {device_name}, Address: {device.address}, RSSI: {device.rssi} dBm\n"
log_file.write(log_entry)
Filtering Devices
The matches_filter()
function checks if the discovered device matches the user’s search criteria, including the target name and address. It returns True
if there’s a match and False
otherwise.
# Function to check if a device matches the given filter
def matches_filter(device, target_name, target_address):
return ((target_name.lower() in (device.name or "").lower()) if target_name else True) and \
((target_address.lower() in device.address.lower()) if target_address else True)
Granting Bluetooth Permissions on Linux/macOS
For this step, let’s take a look at the check_bluetooth_permissions()
function. It uses sys.platform
to figure out if the operating system is Linux or macOS (Darwin):
- If the operating system is Linux, it checks if Bluetooth is blocked using the
rfkill
command. If it is blocked, the function usesos.system()
to unblock it and bring the adapter up, then informs the user that Bluetooth has been unblocked. - If the operating system is macOS, the function prompts the user to manually grant Bluetooth permissions through the system settings, as this typically requires user intervention via System Preferences.
# Function to check and grant Bluetooth permissions on Linux/macOS
def check_bluetooth_permissions(display_box):
if sys.platform == "linux":
# Check if Bluetooth is blocked
rfkill_output = os.popen("rfkill list bluetooth").read()
if "Soft blocked: yes" in rfkill_output or "Hard blocked: yes" in rfkill_output:
display_box.insert(tk.END, "Bluetooth is blocked. Attempting to unblock...\n")
os.system("sudo rfkill unblock bluetooth")
os.system("sudo hciconfig hci0 up")
display_box.insert(tk.END, "Bluetooth unblocked.\n")
display_box.see(tk.END)
elif sys.platform == "darwin":
# macOS-specific check (usually requires system-level permissions)
display_box.insert(tk.END, "Ensure Bluetooth permissions are granted in System Preferences > Security & Privacy > Privacy > Bluetooth.\n")
display_box.see(tk.END)
Starting the Scan and Running Asyncio in a Thread
Even with everything set up, starting the scan might freeze the GUI if we’re not careful. That’s where our scan_button_click()
function comes in. It kicks things off by grabbing the user’s inputs and then clears out any old text from the display_box
using display_box.delete()
.
Next, it spins up a new thread with threading.Thread()
to run the run_scan_thread()
function. This function handles the start_scan()
process asynchronously in the background, so our interface stays smooth and responsive.
# Function to handle the scanning with filters and threading
def scan_button_click():
target_name = name_filter.get()
target_address = address_filter.get()
display_box.delete(1.0, tk.END)
# Start a new thread to run the async scan
scan_thread = threading.Thread(target=run_scan_thread, args=(target_name, target_address))
scan_thread.start()
# Function to run asyncio in a thread
def run_scan_thread(target_name, target_address):
asyncio.run(start_scan(target_name, target_address, display_box))
Building the Main Window
Welcome to the grand finale where everything comes together! We start by building the main window with tk
, setting its title, and then adding two Label
widgets to name the entry boxes we’ll create right after them. Next up, we add a “Start Scan” button that triggers the scan_button_click()
function. We also include the display_box
, a scrollable text widget to show the scan results and any messages.
# Setting up the Tkinter GUI
root = tk.Tk()
root.title("Bluetooth Scanner - The Pycodes")
# Label for name filter
name_label = tk.Label(root, text="Filter by Device Name:")
name_label.grid(column=0, row=0)
# Entry for name filter
name_filter = tk.Entry(root, width=40)
name_filter.grid(column=1, row=0)
# Label for address filter
address_label = tk.Label(root, text="Filter by Device Address:")
address_label.grid(column=0, row=1)
# Entry for address filter
address_filter = tk.Entry(root, width=40)
address_filter.grid(column=1, row=1)
# Button to start scanning
scan_button = tk.Button(root, text="Start Scan", command=scan_button_click)
scan_button.grid(column=0, row=2, columnspan=2)
# Scrolled text box to display the scan results
display_box = scrolledtext.ScrolledText(root, width=60, height=20)
display_box.grid(column=0, row=3, columnspan=2)
To wrap things up, we kick off the main event loop with the mainloop()
method to keep the window running smoothly and responsive to user interactions.
# Run the Tkinter main loop
root.mainloop()
Example
Full Code
import asyncio
import threading
import tkinter as tk
from tkinter import scrolledtext
from bleak import BleakScanner
from bleak.exc import BleakError
import datetime
import os
import sys
# Function to start scanning for Bluetooth devices
async def start_scan(target_name, target_address, display_box):
display_box.insert(tk.END, "Checking Bluetooth adapter status...\n")
display_box.see(tk.END)
# Check Bluetooth permissions and adapter status on Linux/macOS
if sys.platform == "linux" or sys.platform == "darwin":
check_bluetooth_permissions(display_box)
try:
# Increase the scanning timeout to give the adapter more time to detect devices
devices = await BleakScanner.discover(timeout=10) # Scanning for 10 seconds
display_box.insert(tk.END, "Scanning for Bluetooth devices...\n")
display_box.see(tk.END)
if devices:
for device in devices:
log_device(device) # Log all devices detected
display_device(device, display_box) # Show all devices in display box
# Check if it matches filter criteria
if matches_filter(device, target_name, target_address):
display_box.insert(tk.END, f"Matched Device: {device.name or 'Unknown'}, Address: {device.address}\n")
display_box.see(tk.END)
else:
display_box.insert(tk.END, "No devices found.\n")
display_box.see(tk.END)
display_box.insert(tk.END, "Scan complete.\n")
display_box.see(tk.END)
except BleakError as e:
display_box.insert(tk.END, f"Bluetooth error: {e}\n")
display_box.see(tk.END)
except Exception as e:
display_box.insert(tk.END, f"Unexpected error: {e}\n")
display_box.see(tk.END)
# Function to display the discovered devices in the text box
def display_device(device, display_box):
device_name = device.name or "Unknown"
display_box.insert(tk.END, f"Found Device - Name: {device_name}, Address: {device.address}, RSSI: {device.rssi} dBm\n")
display_box.see(tk.END)
# Function to log the discovered devices to a file
def log_device(device):
device_name = device.name or "Unknown"
with open("bluetooth_scan_log.txt", "a") as log_file:
log_entry = f"{datetime.datetime.now()} - Name: {device_name}, Address: {device.address}, RSSI: {device.rssi} dBm\n"
log_file.write(log_entry)
# Function to check if a device matches the given filter
def matches_filter(device, target_name, target_address):
return ((target_name.lower() in (device.name or "").lower()) if target_name else True) and \
((target_address.lower() in device.address.lower()) if target_address else True)
# Function to check and grant Bluetooth permissions on Linux/macOS
def check_bluetooth_permissions(display_box):
if sys.platform == "linux":
# Check if Bluetooth is blocked
rfkill_output = os.popen("rfkill list bluetooth").read()
if "Soft blocked: yes" in rfkill_output or "Hard blocked: yes" in rfkill_output:
display_box.insert(tk.END, "Bluetooth is blocked. Attempting to unblock...\n")
os.system("sudo rfkill unblock bluetooth")
os.system("sudo hciconfig hci0 up")
display_box.insert(tk.END, "Bluetooth unblocked.\n")
display_box.see(tk.END)
elif sys.platform == "darwin":
# macOS-specific check (usually requires system-level permissions)
display_box.insert(tk.END, "Ensure Bluetooth permissions are granted in System Preferences > Security & Privacy > Privacy > Bluetooth.\n")
display_box.see(tk.END)
# Function to handle the scanning with filters and threading
def scan_button_click():
target_name = name_filter.get()
target_address = address_filter.get()
display_box.delete(1.0, tk.END)
# Start a new thread to run the async scan
scan_thread = threading.Thread(target=run_scan_thread, args=(target_name, target_address))
scan_thread.start()
# Function to run asyncio in a thread
def run_scan_thread(target_name, target_address):
asyncio.run(start_scan(target_name, target_address, display_box))
# Setting up the Tkinter GUI
root = tk.Tk()
root.title("Bluetooth Scanner - The Pycodes")
# Label for name filter
name_label = tk.Label(root, text="Filter by Device Name:")
name_label.grid(column=0, row=0)
# Entry for name filter
name_filter = tk.Entry(root, width=40)
name_filter.grid(column=1, row=0)
# Label for address filter
address_label = tk.Label(root, text="Filter by Device Address:")
address_label.grid(column=0, row=1)
# Entry for address filter
address_filter = tk.Entry(root, width=40)
address_filter.grid(column=1, row=1)
# Button to start scanning
scan_button = tk.Button(root, text="Start Scan", command=scan_button_click)
scan_button.grid(column=0, row=2, columnspan=2)
# Scrolled text box to display the scan results
display_box = scrolledtext.ScrolledText(root, width=60, height=20)
display_box.grid(column=0, row=3, columnspan=2)
# Run the Tkinter main loop
root.mainloop()
Happy Coding!