Wendy LogoWendy
Guides & TutorialsPython Guides

Bluetooth Discovery

Build a real-time Bluetooth Low Energy scanner with Server-Sent Events on WendyOS

Scanning for Bluetooth Devices

Bluetooth Low Energy (BLE) is essential for IoT applications, from connecting sensors to communicating with wearables. In this guide, we'll build a real-time Bluetooth discovery application that scans for nearby BLE devices and streams the results to a web interface using Server-Sent Events (SSE).

Prerequisites

  • Wendy CLI installed on your development machine
  • Python 3.14 installed
  • Docker installed (see Docker Installation)
  • A WendyOS device plugged in over USB or connectable over Wi-Fi
  • Bluetooth hardware on your WendyOS device (built-in or USB adapter)

Setting Up Your Project

Create a New Directory

First, create a directory for your project:

mkdir bluetooth-discovery
cd bluetooth-discovery

Create requirements.txt

Create a requirements.txt file with the required dependencies:

fastapi
uvicorn[standard]
bleak
sse-starlette

Dependencies Explained:

  • fastapi - Modern web framework for building APIs
  • uvicorn - ASGI server for running FastAPI
  • bleak - Cross-platform Bluetooth Low Energy library
  • sse-starlette - Server-Sent Events support for Starlette/FastAPI

Create the Application

Create an app.py file with the Bluetooth scanning logic:

#!/usr/bin/env python3
import asyncio
import json
from pathlib import Path
from fastapi import FastAPI
from fastapi.responses import FileResponse
from sse_starlette.sse import EventSourceResponse
from bleak import BleakScanner
from pydantic import BaseModel

app = FastAPI()


class BluetoothDevice(BaseModel):
    name: str | None
    address: str
    rssi: int | None


async def discover_devices(timeout: float = 5.0) -> list[BluetoothDevice]:
    """Discover Bluetooth devices for the specified timeout."""
    devices = await BleakScanner.discover(timeout=timeout)
    return [
        BluetoothDevice(
            name=device.name,
            address=device.address,
            rssi=device.rssi
        )
        for device in devices
    ]


@app.get("/discovered")
async def get_discovered_devices():
    """Return a JSON array of all discovered Bluetooth devices within 5 seconds."""
    devices = await discover_devices(timeout=5.0)
    return [device.model_dump() for device in devices]


@app.get("/events")
async def device_events():
    """SSE endpoint that streams discovered devices in real-time."""
    async def event_generator():
        detection_callback_queue: asyncio.Queue = asyncio.Queue()

        def detection_callback(device, advertisement_data):
            detection_callback_queue.put_nowait({
                "name": device.name,
                "address": device.address,
                "rssi": advertisement_data.rssi
            })

        async with BleakScanner(detection_callback=detection_callback):
            start_time = asyncio.get_event_loop().time()
            while True:
                elapsed = asyncio.get_event_loop().time() - start_time
                if elapsed >= 30:  # Stop after 30 seconds
                    break
                try:
                    device = await asyncio.wait_for(
                        detection_callback_queue.get(),
                        timeout=1.0
                    )
                    yield {
                        "event": "device",
                        "data": json.dumps(device)
                    }
                except asyncio.TimeoutError:
                    # Send keepalive
                    yield {
                        "event": "keepalive",
                        "data": ""
                    }

    return EventSourceResponse(event_generator())


@app.get("/")
async def root():
    """Serve the index.html file."""
    return FileResponse(Path(__file__).parent / "index.html")

Two Scanning Modes: This application provides two ways to discover devices:

  • GET /discovered - One-shot scan that returns all devices found within 5 seconds
  • GET /events - Real-time SSE stream that pushes device discoveries as they happen

Create the Frontend

Create an index.html file that displays discovered devices in a table:

<!doctype html>
<html>
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>Bluetooth Discovery</title>
    <script src="https://cdn.jsdelivr.net/npm/@tailwindcss/browser@4"></script>
  </head>
  <body class="bg-gray-100 min-h-screen p-8">
    <div class="max-w-4xl mx-auto">
      <h1 class="text-3xl font-bold text-gray-800 mb-1">Bluetooth Discovery</h1>
      <p class="mb-2">
        <span id="status" class="inline-flex items-center gap-1.5 text-sm font-medium">
          <span id="status-dot" class="w-2 h-2 rounded-full bg-green-500"></span>
          <span id="status-text" class="text-green-600">Connected</span>
        </span>
      </p>
      <p class="text-gray-600 mb-6">Scanning for nearby Bluetooth devices...</p>

      <div class="bg-white rounded-lg shadow overflow-hidden">
        <table class="min-w-full divide-y divide-gray-200">
          <thead class="bg-gray-50">
            <tr>
              <th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
                Device Name
              </th>
              <th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
                Address
              </th>
              <th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
                RSSI
              </th>
            </tr>
          </thead>
          <tbody id="devices-table" class="bg-white divide-y divide-gray-200">
            <tr id="loading-row">
              <td colspan="3" class="px-6 py-4 text-center text-gray-500">
                <div class="flex items-center justify-center gap-2">
                  <svg class="animate-spin h-5 w-5 text-blue-500" fill="none" viewBox="0 0 24 24">
                    <circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
                    <path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
                  </svg>
                  Scanning for devices...
                </div>
              </td>
            </tr>
          </tbody>
        </table>
      </div>

      <div class="mt-4 text-sm text-gray-500">
        <span id="device-count">0</span> devices discovered
      </div>
    </div>

    <script>
      const devices = new Map();
      const table = document.getElementById('devices-table');
      const loadingRow = document.getElementById('loading-row');
      const deviceCount = document.getElementById('device-count');
      const statusDot = document.getElementById('status-dot');
      const statusText = document.getElementById('status-text');

      function updateTable() {
        if (devices.size > 0 && loadingRow) {
          loadingRow.remove();
        }

        // Sort devices by RSSI (strongest signal first)
        const sortedDevices = [...devices.values()].sort((a, b) =>
          (b.rssi || -100) - (a.rssi || -100)
        );

        table.innerHTML = '';
        for (const device of sortedDevices) {
          const row = document.createElement('tr');
          row.className = 'hover:bg-gray-50 transition-colors';
          row.innerHTML = `
            <td class="px-6 py-4 whitespace-nowrap text-sm font-medium text-gray-900">
              ${device.name || '<span class="text-gray-400 italic">Unknown</span>'}
            </td>
            <td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500 font-mono">
              ${device.address}
            </td>
            <td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
              <span class="inline-flex items-center gap-1">
                ${getRssiIndicator(device.rssi)}
                ${device.rssi !== null ? device.rssi + ' dBm' : 'N/A'}
              </span>
            </td>
          `;
          table.appendChild(row);
        }

        deviceCount.textContent = devices.size;
      }

      function getRssiIndicator(rssi) {
        if (rssi === null) return '';
        let color = 'text-red-500';
        if (rssi > -50) color = 'text-green-500';
        else if (rssi > -70) color = 'text-yellow-500';
        else if (rssi > -80) color = 'text-orange-500';

        return `<svg class="w-4 h-4 ${color}" fill="currentColor" viewBox="0 0 20 20">
          <path d="M9.049 2.927c.3-.921 1.603-.921 1.902 0l1.07 3.292a1 1 0 00.95.69h3.462c.969 0 1.371 1.24.588 1.81l-2.8 2.034a1 1 0 00-.364 1.118l1.07 3.292c.3.921-.755 1.688-1.54 1.118l-2.8-2.034a1 1 0 00-1.175 0l-2.8 2.034c-.784.57-1.838-.197-1.539-1.118l1.07-3.292a1 1 0 00-.364-1.118L2.98 8.72c-.783-.57-.38-1.81.588-1.81h3.461a1 1 0 00.951-.69l1.07-3.292z"/>
        </svg>`;
      }

      // Connect to SSE endpoint
      const eventSource = new EventSource('/events');

      eventSource.addEventListener('device', (event) => {
        const device = JSON.parse(event.data);
        devices.set(device.address, device);
        updateTable();
      });

      eventSource.addEventListener('keepalive', () => {
        // Just a keepalive, no action needed
      });

      eventSource.onerror = () => {
        statusDot.className = 'w-2 h-2 rounded-full bg-red-500';
        statusText.textContent = 'Disconnected';
        statusText.className = 'text-red-600';
      };

      eventSource.onopen = () => {
        statusDot.className = 'w-2 h-2 rounded-full bg-green-500';
        statusText.textContent = 'Connected';
        statusText.className = 'text-green-600';
      };
    </script>
  </body>
</html>

Signal Strength Indicators: The UI shows color-coded signal strength:

  • Green: Strong signal (> -50 dBm)
  • Yellow: Good signal (-50 to -70 dBm)
  • Orange: Weak signal (-70 to -80 dBm)
  • Red: Very weak signal (< -80 dBm)

Create the Dockerfile

Create a Dockerfile in the project root:

# Use uv with Python 3.14 (slim variant for better compatibility)
FROM ghcr.io/astral-sh/uv:python3.14-bookworm-slim

# Install BlueZ dependencies for Bluetooth support
RUN apt-get update && apt-get install -y --no-install-recommends \
    bluez \
    libdbus-1-dev \
    && rm -rf /var/lib/apt/lists/*

# Set working directory
WORKDIR /app

# Copy requirements first for better layer caching
COPY requirements.txt .

# Install Python dependencies at build time (not runtime)
RUN uv pip install --system -r requirements.txt

# Copy application files
COPY app.py .
COPY index.html .

# Create a non-root user for security
RUN useradd --create-home --shell /bin/bash app && \
    chown -R app:app /app
USER app

# Expose port 8000
EXPOSE 8000

# Run the application with uvicorn (dependencies already installed)
CMD ["python", "-m", "uvicorn", "app:app", "--host", "0.0.0.0", "--port", "8000"]

BlueZ Required: The Dockerfile installs BlueZ, the official Linux Bluetooth protocol stack. This is required for the bleak library to communicate with Bluetooth hardware.

Create wendy.json

Create a wendy.json file to configure the application entitlements:

{
    "appId": "sh.wendy.examples.bluetooth-discovery",
    "version": "1.0.0",
    "language": "python",
    "entitlements": [
        {
            "type": "network",
            "mode": "host"
        },
        {
            "type": "bluetooth",
            "mode": "bluez"
        }
    ]
}

Bluetooth Entitlement: The bluetooth entitlement with mode: "bluez" grants the container access to the host's Bluetooth stack via D-Bus. This allows the application to scan for and communicate with BLE devices.

Learn more about available entitlements in the App Entitlements guide.

Deploy to WendyOS Device

Deploy your Bluetooth scanner to your WendyOS device:

wendy run

You'll see output similar to the following:

wendy run
✔︎ Searching for WendyOS devices [5.0s]
✔︎ Which device do you want to run this app on?: Humble Pepper (wendyos-humble-pepper.local) [USB, LAN]
✔︎ Builder ready [0.1s]
✔︎ Container built and uploaded successfully! [12.3s]
ℹ︎ Preparing app
✔︎ App ready to start [0.1s]
✔ Success
  Started app
INFO:     Started server process [41]
INFO:     Application startup complete.
INFO:     Uvicorn running on http://0.0.0.0:8000 (Press CTRL+C to quit)

View Discovered Devices

Open your browser and navigate to:

http://wendyos-humble-pepper.local:8000

Replace the hostname: Each WendyOS device has a unique hostname. Replace wendyos-humble-pepper with your device's actual hostname shown in the CLI output.

You should see a table that populates in real-time as Bluetooth devices are discovered. Devices are sorted by signal strength, with the strongest signals at the top.

Understanding the Architecture

The application uses two complementary patterns for device discovery:

Server-Sent Events (SSE)

The /events endpoint uses SSE to stream device discoveries in real-time:

  1. BleakScanner starts scanning with a detection callback
  2. Each discovered device is pushed to an async queue
  3. The event generator yields devices as SSE events
  4. Keepalive events are sent every second to maintain the connection
  5. Scanning stops after 30 seconds

One-Shot Discovery

The /discovered endpoint provides a simpler alternative:

  1. Scans for 5 seconds
  2. Returns all discovered devices as a JSON array
  3. Useful for polling or when SSE isn't needed

API Reference

EndpointMethodDescription
/GETServes the web interface
/discoveredGETReturns JSON array of devices found in 5-second scan
/eventsGETSSE stream of real-time device discoveries

Device Object

Each discovered device has the following properties:

{
  "name": "Device Name",
  "address": "AA:BB:CC:DD:EE:FF",
  "rssi": -65
}
PropertyTypeDescription
namestring or nullAdvertised device name
addressstringBluetooth MAC address
rssiinteger or nullReceived Signal Strength Indicator in dBm

Next Steps

Now that you have Bluetooth discovery working:

  • Filter devices by name or service UUID
  • Connect to specific devices and read characteristics
  • Implement device pairing and bonding
  • Build a BLE sensor dashboard
  • Create alerts when specific devices appear or disappear