Home » Tutorials » How to Build a Bluetooth Device Scanner in Python

How to Build a Bluetooth Device Scanner in Python

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

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 via scrolledtext.
  • 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 uses os.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!

Leave a Comment

Your email address will not be published. Required fields are marked *

Scroll to Top