Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 2 additions & 20 deletions bluetooth/ble_secure_temp_sensor/CMakeLists.txt
Original file line number Diff line number Diff line change
@@ -1,24 +1,6 @@
# Select a security setting to explore the BLE security
#
# security setting 0: Just works (pairing), no MITM (Man In The Middle) protection
# client and server have no input or output support
#
# security setting 1: Numeric comparison with MITM protection
# client can query yes or no from the user, server has a display only
# server displays passkey
# client displays passkey and user can select Yes or No if they agree the passkey is from the server
#
# security setting 2:
# client has a keyboard and display, server has a display only
# server displays passkey
# client user enters the passkey displayed by the server
#
# security setting 3:
# client has a display only, server has a display and keyboard
# Client displays passkey
# server user enters the passkey displayed by the server
# Select a security setting to explore the BLE security. See README.md for details
if (NOT DEFINED SECURITY_SETTING)
set(SECURITY_SETTING 1)
set(SECURITY_SETTING 0)
endif()

# Standalone example that reads from the on board temperature sensor and sends notifications via BLE
Expand Down
149 changes: 129 additions & 20 deletions bluetooth/ble_secure_temp_sensor/README.md
Original file line number Diff line number Diff line change
@@ -1,28 +1,137 @@
### Secure temp sensor
# Secure temp sensor

This example uses BLE to communicate temperature between a pair of pico Ws. This example is a variant of temp sensor, using LE secure to provide a secure connection.
This example uses BLE to communicate temperature between a pair of Pico Ws. It is a variant of
the temp sensor example, using LE Secure Connections to provide a secure connection.

secure_temp_server is a peripheral or server that transmits its temperature to another device
secure_temp_client is a client that reads a temperature from another device
`secure_temp_server` is a peripheral/server that transmits its temperature to another device.
`secure_temp_client` is a central/client that reads temperature from another device.

In server.c and client.c there is a define SECURITY_SETTING which you can change to explore different security options:
## Security settings

security setting 0: Just works (pairing), no MITM (Man In The Middle) protection
client and server have no input or output support
In `server.c` and `client.c` there is a `SECURITY_SETTING` define which you can change to explore
different BLE security options. Both ends must be built with the same setting unless you are
deliberately testing an asymmetric combination (see the table below).

security setting 1: Numeric comparison with MITM protection
client can query yes or no from the user, server has a display only
server displays passkey
client displays passkey and user can select Yes or No if they agree the passkey is from the server
The settings map to Bluetooth IO capabilities as follows:

security setting 2:
client has a keyboard and display, server has a display only
server displays passkey
client user enters the passkey displayed by the server
| Setting | IO capability | Description |
|---------|--------------|-------------|
| 0 | `NO_INPUT_NO_OUTPUT` | Just Works - no MITM protection |
| 1 | `DISPLAY_YES_NO` | Numeric Comparison - MITM protection |
| 2 | `KEYBOARD_DISPLAY` | Passkey Entry - MITM protection |
| 3 | `DISPLAY_ONLY` | Display Only - MITM protection |

security setting 3:
client has a display only, server has a display and keyboard
Client displays passkey
server user enters the passkey displayed by the server
The actual pairing method used depends on the IO capabilities of *both* devices, not just one.
The Bluetooth SIG defines a matrix of initiator × responder capabilities that determines the
method. Setting `SM_AUTHREQ_MITM_PROTECTION` requests MITM protection but does not guarantee it —
if the negotiated method cannot provide it, pairing will fail.

You will need to use the console with both devices to see the passkeys and answer security prompts. Both stdio over UART and USB are enabled so you can use either.
### Working combinations

| Client setting | Server setting | Pairing method | MITM? |
|---------------|---------------|----------------|-------|
| 0 | 0 | Just Works | No |
| 1 | 1 | Numeric Comparison | Yes |
| 2 | 2 | Passkey Entry | Yes |
| 2 | 3 | Passkey Display (server displays, client types) | Yes |
| 3 | 2 | Passkey Display (client displays, server types) | Yes |
| 3 | 3 | Fails (both display only, nobody can type) | — |
| 0 | >0 | Fails (MITM required but not achievable) | — |

Settings 0, 1 and 2 work symmetrically with the same setting on both ends. Setting 3 is
`DISPLAY_ONLY` so it can only achieve MITM protection when paired with setting 2 on the other end.

You will need a console on each device to see passkeys and answer prompts. Both stdio over UART
and USB are enabled so you can use either.

## Support scripts

Python scripts are provided to make it easier to test with just one Pico W.

> **Note:** Run these scripts on a native Linux host or a Raspberry Pi. WSL2 with a
> usbip-attached Bluetooth adapter can connect and perform GATT discovery, but pairing
> fails during the LE Secure Connections exchange (the DHKey check fails with
> authentication failure / reason 12). This is a limitation of the WSL2 + usbip + BlueZ
> path, not the example code.

### Client (`ble_temp_client.py`)

Acts as a BLE central, connecting to a Pico W running `secure_temp_server`.

```
pip install bleak
python3 ble_temp_client.py [--security <0-3>]
```

- Works with BlueZ running normally — no special setup required.
- If connection fails with a disconnect during service discovery, clear stale bonding info:
```
bluetoothctl remove <addr>
```
The Pico clears its own bond automatically on key mismatch, but BlueZ needs to be told manually.

### Server (`ble_temp_server.py`)

Acts as a BLE peripheral, advertising a simulated temperature for a Pico W running
`secure_temp_client` to connect to.

Uses [Bumble](https://github.com/google/bumble) which talks directly over HCI, bypassing BlueZ.

```
pip install bumble
```

#### Using a USB Bluetooth dongle (recommended)

The easiest option — the dongle is claimed by Bumble leaving the built-in adapter free for BlueZ
and the client script. Find the transport ID with:

```
python3 -m bumble.apps.usb_probe
```

Then run:

```
./ble_temp_server.py --usb <id> [--security <0-3>]
```

#### Using the built-in adapter

BlueZ must be stopped first to release the adapter:

```
sudo systemctl stop bluetooth
sudo systemctl mask bluetooth
```

On Raspberry Pi OS, grant `cap_net_admin` to the Python binary so it can open the HCI socket
without running as root:

```
sudo setcap cap_net_admin+eip $(readlink -f venv/bin/python3)
```

Then run:

```
./ble_temp_server.py --builtin [--security <0-3>]
```

When done, restore BlueZ:

```
sudo systemctl unmask bluetooth
sudo systemctl start bluetooth
```

### Security mode prompts

For settings 1-3 the scripts will prompt for interaction during pairing:

- **Setting 1 (Numeric Comparison)**: The Pico displays a number; confirm it matches when prompted.
- **Setting 2 (Passkey Entry)**: The server displays a passkey; type it into the Pico console.
- **Setting 3 (Display Only)**: The Pico displays a passkey; type it when the script prompts.

For asymmetric combinations (client=2, server=3 or client=3, server=2) the above still applies —
one side displays and the other types, determined by who has `KEYBOARD_DISPLAY` capability.
199 changes: 199 additions & 0 deletions bluetooth/ble_secure_temp_sensor/ble_temp_client.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,199 @@
#!/usr/bin/env python3
"""
BLE test client for the secure_picow_temp example.

Scans for a Pico W running the secure_picow_temp server, connects, pairs,
and prints temperature notifications until Ctrl-C.

Requirements:
pip install bleak

Pairing (including any passkey entry or numeric comparison) is handled by the
host OS / BlueZ agent according to the security level the Pico requests, so no
security option is needed here - just follow any prompts from your OS.

Depending on the security setting the Pico server was built with, expect:
0 - Just Works: no passkey interaction needed.
1 - Numeric Comparison: the Pico displays a number on its serial console;
your OS asks you to confirm it matches.
2 - Passkey Entry: the Pico displays a 6-digit passkey on its serial
console; enter it when prompted by your OS.
3 - Passkey Display: your OS displays a passkey; enter it on the Pico's
serial console.

Usage:
python ble_temp_client.py [--scan-timeout <seconds>] [--debug]

Tips:
- If you get a disconnect during service discovery, clear stale bonding info:
bluetoothctl remove <addr>
The Pico clears its own bond automatically on key mismatch.
- The Pico advertises as "Pico <bdaddr>" not "secure_picow_temp"; the latter
is the GATT device name only readable after connecting.
"""

import argparse
import asyncio
import logging
import struct
import sys

try:
from bleak import BleakClient, BleakScanner
from bleak.backends.characteristic import BleakGATTCharacteristic
except ImportError:
print("ERROR: bleak is not installed. Run: pip install bleak")
sys.exit(1)

# ---------------------------------------------------------------------------
# Constants matching the Pico example
# ---------------------------------------------------------------------------

# Environmental Sensing Service (0x181A) and Temperature characteristic (0x2A6E)
ENVIRONMENTAL_SENSING_SERVICE_UUID = "0000181a-0000-1000-8000-00805f9b34fb"
TEMPERATURE_CHARACTERISTIC_UUID = "00002a6e-0000-1000-8000-00805f9b34fb"

# Temperature is a signed 16-bit integer in units of 0.01 degC (Bluetooth SIG spec).
# The Pico stores current_temp = deg_c * 100 as uint16_t, which matches.
TEMP_SCALE = 100.0

# ---------------------------------------------------------------------------
# Logging
# ---------------------------------------------------------------------------
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s [%(levelname)s] %(message)s",
datefmt="%H:%M:%S",
)
log = logging.getLogger(__name__)

# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------

def decode_temperature(data: bytes) -> float:
"""Decode a 2-byte little-endian temperature value (units: 0.01 degC)."""
if len(data) != 2:
raise ValueError(f"Expected 2 bytes for temperature, got {len(data)}")
raw = struct.unpack_from("<h", data)[0] # signed little-endian int16
return raw / TEMP_SCALE


def notification_handler(characteristic: BleakGATTCharacteristic,
data: bytearray) -> None:
"""Called each time the server sends a temperature notification."""
try:
temp = decode_temperature(bytes(data))
log.info("Temperature notification: %.2f degC (raw: %s)", temp, data.hex())
except ValueError as exc:
log.error("Failed to decode notification: %s raw=%s", exc, data.hex())


# ---------------------------------------------------------------------------
# Main client coroutine
# ---------------------------------------------------------------------------

async def run(timeout: float) -> None:
# The Pico advertises the Environmental Sensing service UUID (0x181A).
# The advertised name is "Pico <bdaddr>" so we match on service UUID,
# with a name-prefix fallback in case BlueZ does not surface the UUID.
log.info("Scanning for Environmental Sensing service (0x181A) or name prefix 'Pico '...")

ESS_UUID_SHORT = "181a"

def matches_pico(d, adv) -> bool:
by_uuid = any(
str(u).lower().replace("-", "").endswith(ESS_UUID_SHORT)
for u in adv.service_uuids
)
by_name = (d.name or "").startswith("Pico ")
if d.name or adv.service_uuids:
log.debug(" Seen: %s name=%r uuids=%s by_uuid=%s by_name=%s",
d.address, d.name, [str(u) for u in adv.service_uuids],
by_uuid, by_name)
return by_uuid or by_name

device = await BleakScanner.find_device_by_filter(matches_pico, timeout=timeout)

if device is None:
log.error(
"No device found within %.0f s. Make sure the Pico W server is "
"running and advertising. Re-run with --debug to see all visible devices.",
timeout,
)
return

log.info("Found device: %s [%s]", device.name, device.address)

def disconnected_callback(client: BleakClient) -> None:
log.warning("Device disconnected.")

# pair_before_connect=True is essential: the BlueZ backend runs service
# discovery automatically inside connect(), before we can call pair()
# manually. The Temperature characteristic requires ENCRYPTION_KEY_SIZE_16,
# so the Pico rejects unencrypted ATT traffic and BlueZ drops the connection
# mid-discovery unless pairing is completed first.
log.info("Connecting and pairing...")
async with BleakClient(device, timeout=10.0,
pair_before_connect=True,
disconnected_callback=disconnected_callback) as client:
log.info("Connected and paired.")

# Initial read to confirm the encrypted link is working before writing
# the CCCD. BTstack silently rejects ATT writes on insufficiently
# secure links, so doing a read first ensures we are definitely encrypted.
try:
data = await client.read_gatt_char(TEMPERATURE_CHARACTERISTIC_UUID)
log.info("Initial read: %.2f degC", decode_temperature(bytes(data)))
except Exception as exc:
log.warning("Initial read failed: %s", exc)

log.info("Subscribing to temperature notifications...")
await client.start_notify(TEMPERATURE_CHARACTERISTIC_UUID, notification_handler)
log.info("Subscribed. Listening for notifications - press Ctrl-C to stop.")

try:
while True:
await asyncio.sleep(5)
if not client.is_connected:
log.warning("Connection lost.")
break
except asyncio.CancelledError:
pass
finally:
if client.is_connected:
await client.stop_notify(TEMPERATURE_CHARACTERISTIC_UUID)
log.info("Unsubscribed and disconnecting.")


# ---------------------------------------------------------------------------
# Entry point
# ---------------------------------------------------------------------------

def main() -> None:
parser = argparse.ArgumentParser(
description="BLE client for the secure_picow_temp example.",
formatter_class=argparse.RawDescriptionHelpFormatter,
epilog=__doc__,
)
parser.add_argument(
"--scan-timeout", type=float, default=30.0,
help="Seconds to scan before giving up. Default: 30.",
)
parser.add_argument(
"--debug", action="store_true",
help="Print every BLE advertisement seen during the scan.",
)
args = parser.parse_args()

if args.debug:
logging.getLogger().setLevel(logging.DEBUG)

try:
asyncio.run(run(timeout=args.scan_timeout))
except KeyboardInterrupt:
log.info("Interrupted - goodbye.")


if __name__ == "__main__":
main()
Loading
Loading