---
title: Bluetooth Discovery
description: Build a real-time Bluetooth Low Energy scanner with Server-Sent Events on WendyOS
language: python
---

## 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
- 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

<Steps>
  <Step>
    ### Initialize the Project

    Create a Python Wendy project in the current directory:

    ```bash
    mkdir bluetooth-discovery
    cd bluetooth-discovery
    wendy init --app-id bluetooth-discovery --target wendyos --language python --no-extra-entitlements --assistant skip
    ```

    <CliShot flow="init" step="wizard" alt="wendy init scaffolding the bluetooth-discovery Python project" />

    This creates `wendy.json` and a starter Python project. Continue below to add the BLE dependencies, app code, and Bluetooth entitlement.
  </Step>

  <Step>
    ### Create requirements.txt

    Create a `requirements.txt` file with the required dependencies:

    ```txt
    fastapi
    uvicorn[standard]
    bleak
    sse-starlette
    ```

    <Callout type="info">
      **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
    </Callout>
  </Step>

  <Step>
    ### Create the Application

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

    ```python
    #!/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")
    ```

    <Callout type="info">
      **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
    </Callout>
  </Step>

  <Step>
    ### Create the Frontend

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

    ```html
    <!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>
    ```

    <Callout type="info">
      **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)
    </Callout>
  </Step>

  <Step>
    ### Create the Dockerfile

    Create a `Dockerfile` in the project root:

    ```dockerfile
    # 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"]
    ```

    <Callout type="warning">
      **BlueZ Required**: The Dockerfile installs BlueZ, the official Linux Bluetooth protocol stack. This is required for the `bleak` library to communicate with Bluetooth hardware.
    </Callout>
  </Step>

  <Step>
    ### Update wendy.json

    Update the generated `wendy.json` file to configure the application entitlements:

    ```json
    {
        "appId": "bluetooth-discovery",
        "platform": "linux",
        "version": "0.1.0",
        "entitlements": [
            {
                "type": "network",
                "mode": "host"
            },
            {
                "type": "bluetooth",
                "mode": "bluez"
            }
        ],
        "readiness": {
            "tcpSocket": { "port": 8000 },
            "timeoutSeconds": 30
        },
        "hooks": {
            "postStart": {
                "cli": "wendy utils open-browser http://${WENDY_HOSTNAME}:8000"
            }
        }
    }
    ```

    <Callout type="info">
      **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](../../../../device/entitlements/) guide.
    </Callout>
    </Step>
</Steps>

## Run Again on WendyOS

Deploy your Bluetooth scanner to your WendyOS device:

```bash
wendy run
```

<CliShot flow="run" step="deploy" alt="wendy run building and deploying the Bluetooth discovery app to a WendyOS device" />

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

Your browser will open automatically once the server is ready, thanks to the `postStart` hook. If it doesn't, navigate to:

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

<Callout type="warning">
  **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.
</Callout>

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

| Endpoint | Method | Description |
|----------|--------|-------------|
| `/` | GET | Serves the web interface |
| `/discovered` | GET | Returns JSON array of devices found in 5-second scan |
| `/events` | GET | SSE stream of real-time device discoveries |

### Device Object

Each discovered device has the following properties:

```json
{
  "name": "Device Name",
  "address": "AA:BB:CC:DD:EE:FF",
  "rssi": -65
}
```

| Property | Type | Description |
|----------|------|-------------|
| `name` | string or null | Advertised device name |
| `address` | string | Bluetooth MAC address |
| `rssi` | integer or null | Received 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

<Accordions>
  <Accordion title="Filtering by Service UUID">
    You can modify the scanner to filter for specific BLE services. For example, to find only heart rate monitors:

    ```python
    from bleak import BleakScanner

    HEART_RATE_SERVICE = "0000180d-0000-1000-8000-00805f9b34fb"

    async def discover_heart_rate_devices():
        devices = await BleakScanner.discover(
            timeout=5.0,
            service_uuids=[HEART_RATE_SERVICE]
        )
        return devices
    ```

    Common service UUIDs:
    - Heart Rate: `0000180d-0000-1000-8000-00805f9b34fb`
    - Battery Service: `0000180f-0000-1000-8000-00805f9b34fb`
    - Device Information: `0000180a-0000-1000-8000-00805f9b34fb`
  </Accordion>

  <Accordion title="Testing Locally Without a Device">
    You can test the application on your development machine if you have Bluetooth hardware:

    ### Using Python Directly

    ```bash
    pip install -r requirements.txt
    uvicorn app:app --host 0.0.0.0 --port 8000
    ```

    Then open `http://localhost:8000` in your browser.

    ### Using Docker

    On Linux, you can run with Bluetooth access:

    ```bash
    docker build -t bluetooth-discovery .
    docker run -p 8000:8000 \
      --net=host \
      -v /var/run/dbus:/var/run/dbus \
      bluetooth-discovery
    ```

    Note: Docker Bluetooth access on macOS and Windows requires additional configuration.
  </Accordion>

  <Accordion title="Connecting to a Device">
    Once you've discovered a device, you can connect and read its characteristics:

    ```python
    from bleak import BleakClient

    async def read_battery_level(address: str) -> int:
        async with BleakClient(address) as client:
            # Battery Level characteristic UUID
            battery_uuid = "00002a19-0000-1000-8000-00805f9b34fb"
            data = await client.read_gatt_char(battery_uuid)
            return int(data[0])
    ```

    This opens a connection, reads the battery level characteristic, and automatically disconnects when done.
  </Accordion>
</Accordions>
