Skip to content

Commit 3cbc84b

Browse files
Improve ble secure temp sensor (#767)
* Improvements from testing Some of the code is a bit confused - tidy it up a bit. Make the configuration of the client runtime configurable. Add host scripts which makes testing / experimentation easier. * Update to server The server uses different contraints for the secrity setting. I think this was to account for the fact that certain combinations won't work. But it's confusing, so make the client and server behave the same. Add a menu for the security setting to the server (like the client). Update the readme about WSL issues. * Remove redundant code HCI_EVENT_LE_META / HCI_SUBEVENT_LE_CONNECTION_COMPLETE is not received when ENABLE_LE_ENHANCED_CONNECTION_COMPLETE_EVENT is set in config
1 parent 0315e0d commit 3cbc84b

6 files changed

Lines changed: 735 additions & 104 deletions

File tree

bluetooth/ble_secure_temp_sensor/CMakeLists.txt

Lines changed: 2 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -1,24 +1,6 @@
1-
# Select a security setting to explore the BLE security
2-
#
3-
# security setting 0: Just works (pairing), no MITM (Man In The Middle) protection
4-
# client and server have no input or output support
5-
#
6-
# security setting 1: Numeric comparison with MITM protection
7-
# client can query yes or no from the user, server has a display only
8-
# server displays passkey
9-
# client displays passkey and user can select Yes or No if they agree the passkey is from the server
10-
#
11-
# security setting 2:
12-
# client has a keyboard and display, server has a display only
13-
# server displays passkey
14-
# client user enters the passkey displayed by the server
15-
#
16-
# security setting 3:
17-
# client has a display only, server has a display and keyboard
18-
# Client displays passkey
19-
# server user enters the passkey displayed by the server
1+
# Select a security setting to explore the BLE security. See README.md for details
202
if (NOT DEFINED SECURITY_SETTING)
21-
set(SECURITY_SETTING 1)
3+
set(SECURITY_SETTING 0)
224
endif()
235

246
# Standalone example that reads from the on board temperature sensor and sends notifications via BLE
Lines changed: 129 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -1,28 +1,137 @@
1-
### Secure temp sensor
1+
# Secure temp sensor
22

3-
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.
3+
This example uses BLE to communicate temperature between a pair of Pico Ws. It is a variant of
4+
the temp sensor example, using LE Secure Connections to provide a secure connection.
45

5-
secure_temp_server is a peripheral or server that transmits its temperature to another device
6-
secure_temp_client is a client that reads a temperature from another device
6+
`secure_temp_server` is a peripheral/server that transmits its temperature to another device.
7+
`secure_temp_client` is a central/client that reads temperature from another device.
78

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

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

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

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

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

28-
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.
29+
### Working combinations
30+
31+
| Client setting | Server setting | Pairing method | MITM? |
32+
|---------------|---------------|----------------|-------|
33+
| 0 | 0 | Just Works | No |
34+
| 1 | 1 | Numeric Comparison | Yes |
35+
| 2 | 2 | Passkey Entry | Yes |
36+
| 2 | 3 | Passkey Display (server displays, client types) | Yes |
37+
| 3 | 2 | Passkey Display (client displays, server types) | Yes |
38+
| 3 | 3 | Fails (both display only, nobody can type) ||
39+
| 0 | >0 | Fails (MITM required but not achievable) ||
40+
41+
Settings 0, 1 and 2 work symmetrically with the same setting on both ends. Setting 3 is
42+
`DISPLAY_ONLY` so it can only achieve MITM protection when paired with setting 2 on the other end.
43+
44+
You will need a console on each device to see passkeys and answer prompts. Both stdio over UART
45+
and USB are enabled so you can use either.
46+
47+
## Support scripts
48+
49+
Python scripts are provided to make it easier to test with just one Pico W.
50+
51+
> **Note:** Run these scripts on a native Linux host or a Raspberry Pi. WSL2 with a
52+
> usbip-attached Bluetooth adapter can connect and perform GATT discovery, but pairing
53+
> fails during the LE Secure Connections exchange (the DHKey check fails with
54+
> authentication failure / reason 12). This is a limitation of the WSL2 + usbip + BlueZ
55+
> path, not the example code.
56+
57+
### Client (`ble_temp_client.py`)
58+
59+
Acts as a BLE central, connecting to a Pico W running `secure_temp_server`.
60+
61+
```
62+
pip install bleak
63+
python3 ble_temp_client.py [--security <0-3>]
64+
```
65+
66+
- Works with BlueZ running normally — no special setup required.
67+
- If connection fails with a disconnect during service discovery, clear stale bonding info:
68+
```
69+
bluetoothctl remove <addr>
70+
```
71+
The Pico clears its own bond automatically on key mismatch, but BlueZ needs to be told manually.
72+
73+
### Server (`ble_temp_server.py`)
74+
75+
Acts as a BLE peripheral, advertising a simulated temperature for a Pico W running
76+
`secure_temp_client` to connect to.
77+
78+
Uses [Bumble](https://github.com/google/bumble) which talks directly over HCI, bypassing BlueZ.
79+
80+
```
81+
pip install bumble
82+
```
83+
84+
#### Using a USB Bluetooth dongle (recommended)
85+
86+
The easiest option — the dongle is claimed by Bumble leaving the built-in adapter free for BlueZ
87+
and the client script. Find the transport ID with:
88+
89+
```
90+
python3 -m bumble.apps.usb_probe
91+
```
92+
93+
Then run:
94+
95+
```
96+
./ble_temp_server.py --usb <id> [--security <0-3>]
97+
```
98+
99+
#### Using the built-in adapter
100+
101+
BlueZ must be stopped first to release the adapter:
102+
103+
```
104+
sudo systemctl stop bluetooth
105+
sudo systemctl mask bluetooth
106+
```
107+
108+
On Raspberry Pi OS, grant `cap_net_admin` to the Python binary so it can open the HCI socket
109+
without running as root:
110+
111+
```
112+
sudo setcap cap_net_admin+eip $(readlink -f venv/bin/python3)
113+
```
114+
115+
Then run:
116+
117+
```
118+
./ble_temp_server.py --builtin [--security <0-3>]
119+
```
120+
121+
When done, restore BlueZ:
122+
123+
```
124+
sudo systemctl unmask bluetooth
125+
sudo systemctl start bluetooth
126+
```
127+
128+
### Security mode prompts
129+
130+
For settings 1-3 the scripts will prompt for interaction during pairing:
131+
132+
- **Setting 1 (Numeric Comparison)**: The Pico displays a number; confirm it matches when prompted.
133+
- **Setting 2 (Passkey Entry)**: The server displays a passkey; type it into the Pico console.
134+
- **Setting 3 (Display Only)**: The Pico displays a passkey; type it when the script prompts.
135+
136+
For asymmetric combinations (client=2, server=3 or client=3, server=2) the above still applies —
137+
one side displays and the other types, determined by who has `KEYBOARD_DISPLAY` capability.
Lines changed: 199 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,199 @@
1+
#!/usr/bin/env python3
2+
"""
3+
BLE test client for the secure_picow_temp example.
4+
5+
Scans for a Pico W running the secure_picow_temp server, connects, pairs,
6+
and prints temperature notifications until Ctrl-C.
7+
8+
Requirements:
9+
pip install bleak
10+
11+
Pairing (including any passkey entry or numeric comparison) is handled by the
12+
host OS / BlueZ agent according to the security level the Pico requests, so no
13+
security option is needed here - just follow any prompts from your OS.
14+
15+
Depending on the security setting the Pico server was built with, expect:
16+
0 - Just Works: no passkey interaction needed.
17+
1 - Numeric Comparison: the Pico displays a number on its serial console;
18+
your OS asks you to confirm it matches.
19+
2 - Passkey Entry: the Pico displays a 6-digit passkey on its serial
20+
console; enter it when prompted by your OS.
21+
3 - Passkey Display: your OS displays a passkey; enter it on the Pico's
22+
serial console.
23+
24+
Usage:
25+
python ble_temp_client.py [--scan-timeout <seconds>] [--debug]
26+
27+
Tips:
28+
- If you get a disconnect during service discovery, clear stale bonding info:
29+
bluetoothctl remove <addr>
30+
The Pico clears its own bond automatically on key mismatch.
31+
- The Pico advertises as "Pico <bdaddr>" not "secure_picow_temp"; the latter
32+
is the GATT device name only readable after connecting.
33+
"""
34+
35+
import argparse
36+
import asyncio
37+
import logging
38+
import struct
39+
import sys
40+
41+
try:
42+
from bleak import BleakClient, BleakScanner
43+
from bleak.backends.characteristic import BleakGATTCharacteristic
44+
except ImportError:
45+
print("ERROR: bleak is not installed. Run: pip install bleak")
46+
sys.exit(1)
47+
48+
# ---------------------------------------------------------------------------
49+
# Constants matching the Pico example
50+
# ---------------------------------------------------------------------------
51+
52+
# Environmental Sensing Service (0x181A) and Temperature characteristic (0x2A6E)
53+
ENVIRONMENTAL_SENSING_SERVICE_UUID = "0000181a-0000-1000-8000-00805f9b34fb"
54+
TEMPERATURE_CHARACTERISTIC_UUID = "00002a6e-0000-1000-8000-00805f9b34fb"
55+
56+
# Temperature is a signed 16-bit integer in units of 0.01 degC (Bluetooth SIG spec).
57+
# The Pico stores current_temp = deg_c * 100 as uint16_t, which matches.
58+
TEMP_SCALE = 100.0
59+
60+
# ---------------------------------------------------------------------------
61+
# Logging
62+
# ---------------------------------------------------------------------------
63+
logging.basicConfig(
64+
level=logging.INFO,
65+
format="%(asctime)s [%(levelname)s] %(message)s",
66+
datefmt="%H:%M:%S",
67+
)
68+
log = logging.getLogger(__name__)
69+
70+
# ---------------------------------------------------------------------------
71+
# Helpers
72+
# ---------------------------------------------------------------------------
73+
74+
def decode_temperature(data: bytes) -> float:
75+
"""Decode a 2-byte little-endian temperature value (units: 0.01 degC)."""
76+
if len(data) != 2:
77+
raise ValueError(f"Expected 2 bytes for temperature, got {len(data)}")
78+
raw = struct.unpack_from("<h", data)[0] # signed little-endian int16
79+
return raw / TEMP_SCALE
80+
81+
82+
def notification_handler(characteristic: BleakGATTCharacteristic,
83+
data: bytearray) -> None:
84+
"""Called each time the server sends a temperature notification."""
85+
try:
86+
temp = decode_temperature(bytes(data))
87+
log.info("Temperature notification: %.2f degC (raw: %s)", temp, data.hex())
88+
except ValueError as exc:
89+
log.error("Failed to decode notification: %s raw=%s", exc, data.hex())
90+
91+
92+
# ---------------------------------------------------------------------------
93+
# Main client coroutine
94+
# ---------------------------------------------------------------------------
95+
96+
async def run(timeout: float) -> None:
97+
# The Pico advertises the Environmental Sensing service UUID (0x181A).
98+
# The advertised name is "Pico <bdaddr>" so we match on service UUID,
99+
# with a name-prefix fallback in case BlueZ does not surface the UUID.
100+
log.info("Scanning for Environmental Sensing service (0x181A) or name prefix 'Pico '...")
101+
102+
ESS_UUID_SHORT = "181a"
103+
104+
def matches_pico(d, adv) -> bool:
105+
by_uuid = any(
106+
str(u).lower().replace("-", "").endswith(ESS_UUID_SHORT)
107+
for u in adv.service_uuids
108+
)
109+
by_name = (d.name or "").startswith("Pico ")
110+
if d.name or adv.service_uuids:
111+
log.debug(" Seen: %s name=%r uuids=%s by_uuid=%s by_name=%s",
112+
d.address, d.name, [str(u) for u in adv.service_uuids],
113+
by_uuid, by_name)
114+
return by_uuid or by_name
115+
116+
device = await BleakScanner.find_device_by_filter(matches_pico, timeout=timeout)
117+
118+
if device is None:
119+
log.error(
120+
"No device found within %.0f s. Make sure the Pico W server is "
121+
"running and advertising. Re-run with --debug to see all visible devices.",
122+
timeout,
123+
)
124+
return
125+
126+
log.info("Found device: %s [%s]", device.name, device.address)
127+
128+
def disconnected_callback(client: BleakClient) -> None:
129+
log.warning("Device disconnected.")
130+
131+
# pair_before_connect=True is essential: the BlueZ backend runs service
132+
# discovery automatically inside connect(), before we can call pair()
133+
# manually. The Temperature characteristic requires ENCRYPTION_KEY_SIZE_16,
134+
# so the Pico rejects unencrypted ATT traffic and BlueZ drops the connection
135+
# mid-discovery unless pairing is completed first.
136+
log.info("Connecting and pairing...")
137+
async with BleakClient(device, timeout=10.0,
138+
pair_before_connect=True,
139+
disconnected_callback=disconnected_callback) as client:
140+
log.info("Connected and paired.")
141+
142+
# Initial read to confirm the encrypted link is working before writing
143+
# the CCCD. BTstack silently rejects ATT writes on insufficiently
144+
# secure links, so doing a read first ensures we are definitely encrypted.
145+
try:
146+
data = await client.read_gatt_char(TEMPERATURE_CHARACTERISTIC_UUID)
147+
log.info("Initial read: %.2f degC", decode_temperature(bytes(data)))
148+
except Exception as exc:
149+
log.warning("Initial read failed: %s", exc)
150+
151+
log.info("Subscribing to temperature notifications...")
152+
await client.start_notify(TEMPERATURE_CHARACTERISTIC_UUID, notification_handler)
153+
log.info("Subscribed. Listening for notifications - press Ctrl-C to stop.")
154+
155+
try:
156+
while True:
157+
await asyncio.sleep(5)
158+
if not client.is_connected:
159+
log.warning("Connection lost.")
160+
break
161+
except asyncio.CancelledError:
162+
pass
163+
finally:
164+
if client.is_connected:
165+
await client.stop_notify(TEMPERATURE_CHARACTERISTIC_UUID)
166+
log.info("Unsubscribed and disconnecting.")
167+
168+
169+
# ---------------------------------------------------------------------------
170+
# Entry point
171+
# ---------------------------------------------------------------------------
172+
173+
def main() -> None:
174+
parser = argparse.ArgumentParser(
175+
description="BLE client for the secure_picow_temp example.",
176+
formatter_class=argparse.RawDescriptionHelpFormatter,
177+
epilog=__doc__,
178+
)
179+
parser.add_argument(
180+
"--scan-timeout", type=float, default=30.0,
181+
help="Seconds to scan before giving up. Default: 30.",
182+
)
183+
parser.add_argument(
184+
"--debug", action="store_true",
185+
help="Print every BLE advertisement seen during the scan.",
186+
)
187+
args = parser.parse_args()
188+
189+
if args.debug:
190+
logging.getLogger().setLevel(logging.DEBUG)
191+
192+
try:
193+
asyncio.run(run(timeout=args.scan_timeout))
194+
except KeyboardInterrupt:
195+
log.info("Interrupted - goodbye.")
196+
197+
198+
if __name__ == "__main__":
199+
main()

0 commit comments

Comments
 (0)