This guide explains how to control an LED on the UNO Q from an ESP32 over Bluetooth BLE. The basic environment setup (SSH login, virtual environment, arduino package installation) has already been covered in the previous WiFi tutorial, so here we only give a brief recap. If anything is unfamiliar, please refer back to the earlier chapters.
The reason is the same as in the WiFi scenario: App Lab's containerized runtime restricts Bridge imports and does not expose network ports. Bluetooth has one additional difference——
The UNO Q's Bluetooth module lives on the Linux side, so establishing the BLE connection and receiving data must be handled by the Python program on the Linux side; while the actual LED control via digitalWrite must happen on the MCU side. The two sides must cooperate through Bridge.
Therefore the overall architecture is the same as the WiFi approach:
Similar to the WiFi section, you only need to install the new library dependency here.
ssh arduino@your_ip
Replace your_ip with your board's actual IP, for example:
ssh arduino@192.168.102.51
cd ~/my_flask_project
source venv/bin/activate
python3 -m pip install bleak
python3 -c "from arduino.app_utils import App, Bridge; print('OK')"
Every time you open a new SSH terminal, you must run
source venv/bin/activateagain, otherwisebleakandarduinowill not be found.
ESP32 (BLE Server)
↓ Sends "ON" / "OFF" via the Notify characteristic
UNO Q Linux side (BLE Client, bleak library)
↓ Calls Bridge.call("set_led_state", True/False)
UNO Q MCU side (Arduino Sketch)
↓ Executes digitalWrite(LED_PIN, HIGH/LOW)
External LED
Data flow: The ESP32 acts as the BLE server and creates two characteristics — one writable (so the UNO Q can write back), and one notifiable (so the ESP32 can push commands).
The UNO Q Linux side uses bleak as a client to connect to the ESP32 and subscribes to the notify characteristic.
When the ESP32 triggers an ON/OFF command via serial input, the UNO Q receives a notification, parses it, and then calls a function registered on the MCU through Bridge. The MCU finally executes digitalWrite to control the LED.
Why not let the UNO Q act as the BLE server?
Because implementing a BLE peripheral on Linux requires more complex libraries such as bluez-peripheral, whereas bleak is itself a client library and is easiest to use as a client. So the role assignment is: ESP32 as server, UNO Q as client.
Create ble.py under ~/my_flask_project/ on the UNO Q, or write it locally and upload it via SSH:
import asyncio
from bleak import BleakScanner, BleakClient
from arduino.app_utils import Bridge
ESP32_NAME = "ESP32-BLE-Server"
CHAR_NOTIFY_UUID = "beb5483e-36e1-4688-b7f5-ea07361b26a9"
def status_handler(sender, data: bytearray):
cmd = data.decode("utf-8").strip()
print(f"Received from ESP32: {cmd}")
if cmd == "ON":
Bridge.call("set_led_state", True)
elif cmd == "OFF":
Bridge.call("set_led_state", False)
async def find_esp32():
print("Scanning for ESP32...")
devices = await BleakScanner.discover(timeout=5.0)
for d in devices:
if d.name == ESP32_NAME:
print(f"Found: {d.name} ({d.address})")
return d.address
return None
async def main():
address = await find_esp32()
if address is None:
print("ESP32 not found. Make sure it is flashed and powered on.")
return
async with BleakClient(address) as client:
print("Connected to ESP32")
await client.start_notify(CHAR_NOTIFY_UUID, status_handler)
print("Subscribed to ESP32 notifications, waiting for commands...")
while True:
await asyncio.sleep(1)
asyncio.run(main())
Run:
python3 ble.py
Flash with Arduino IDE, select Arduino UNO Q under Tools > Board:
#include "Arduino_RouterBridge.h"
const int LED_PIN = 7; // Change according to your actual wiring
void set_led_state(bool state) {
digitalWrite(LED_PIN, state ? HIGH : LOW);
}
void setup() {
pinMode(LED_PIN, OUTPUT);
digitalWrite(LED_PIN, LOW);
Bridge.begin();
Bridge.provide("set_led_state", set_led_state);
}
void loop() {
Bridge.update();
}
Flash with Arduino IDE, select ESP32 Dev Module under Tools > Board (do not select UNO Q, or you will get BLEDevice.h: No such file or directory):
#include <BLEDevice.h>
#include <BLEUtils.h>
#include <BLEServer.h>
#define SERVICE_UUID "4fafc201-1fb5-459e-8fcc-c5c9c331914b"
#define CHAR_WRITE_UUID "beb5483e-36e1-4688-b7f5-ea07361b26a8"
#define CHAR_NOTIFY_UUID "beb5483e-36e1-4688-b7f5-ea07361b26a9"
#define LED_PIN 2
BLECharacteristic *pNotifyChar = nullptr;
class MyCallbacks : public BLECharacteristicCallbacks {
void onWrite(BLECharacteristic *pCharacteristic) {
String value = pCharacteristic->getValue();
if (value.length() > 0) {
Serial.print("Received from UNO Q: ");
Serial.println(value.c_str());
if (value == "ON") {
digitalWrite(LED_PIN, HIGH);
} else if (value == "OFF") {
digitalWrite(LED_PIN, LOW);
}
}
}
};
void setup() {
Serial.begin(115200);
pinMode(LED_PIN, OUTPUT);
digitalWrite(LED_PIN, LOW);
BLEDevice::init("ESP32-BLE-Server");
BLEServer *pServer = BLEDevice::createServer();
BLEService *pService = pServer->createService(SERVICE_UUID);
BLECharacteristic *pWriteChar = pService->createCharacteristic(
CHAR_WRITE_UUID,
BLECharacteristic::PROPERTY_WRITE
);
pWriteChar->setCallbacks(new MyCallbacks());
pNotifyChar = pService->createCharacteristic(
CHAR_NOTIFY_UUID,
BLECharacteristic::PROPERTY_READ | BLECharacteristic::PROPERTY_NOTIFY
);
pNotifyChar->setValue("OFF");
pService->start();
BLEAdvertising *pAdvertising = BLEDevice::getAdvertising();
pAdvertising->addServiceUUID(SERVICE_UUID);
pAdvertising->start();
Serial.println("BLE service started, waiting for UNO Q to connect...");
Serial.println("Type ON or OFF in Serial Monitor to control UNO Q's LED");
}
void loop() {
if (Serial.available()) {
String cmd = Serial.readStringUntil('\n');
cmd.trim();
if (cmd == "ON" || cmd == "OFF") {
pNotifyChar->setValue(cmd.c_str());
pNotifyChar->notify();
Serial.print("Notification sent: ");
Serial.println(cmd);
}
}
delay(50);
}
Code block: Importing modules
import asyncio
from bleak import BleakScanner, BleakClient
from arduino.app_utils import Bridge
Purpose: Import required modules
import asyncio: Imports the asynchronous IO framework. All Bluetooth operations in bleak are asynchronous and must be driven by asyncio.from bleak import BleakScanner, BleakClient: BleakScanner is used to scan for nearby BLE devices, and BleakClient is used to establish connections and read/write characteristics.from arduino.app_utils import Bridge: Imports Bridge from the official Arduino Python package, used by the Linux side to call functions registered on the MCU.Code block: Defining global variables
ESP32_NAME = "ESP32-BLE-Server"
CHAR_NOTIFY_UUID = "beb5483e-36e1-4688-b7f5-ea07361b26a9"
Purpose: Define the target device name and characteristic UUID
ESP32_NAME: Used during scanning to match the target device. It must match BLEDevice::init("ESP32-BLE-Server") on the ESP32 side exactly.CHAR_NOTIFY_UUID: The UUID of the notify characteristic used by the ESP32 to push commands. It must match CHAR_NOTIFY_UUID on the ESP32 side exactly.Code block: Defining the notification callback
def status_handler(sender, data: bytearray):
cmd = data.decode("utf-8").strip()
print(f"Received from ESP32: {cmd}")
if cmd == "ON":
Bridge.call("set_led_state", True)
elif cmd == "OFF":
Bridge.call("set_led_state", False)
Purpose: Handle notifications from the ESP32
def status_handler(sender, data): Defines the callback. bleak's start_notify requires the callback signature (sender, data), where sender is the characteristic object and data is the received byte data.data.decode("utf-8").strip(): Decodes the byte data into a string and strips leading/trailing whitespace to avoid interference from newline characters.Bridge.call("set_led_state", True): Calls the set_led_state function registered on the MCU via Bridge, passing True to turn the LED on. The function name must match the one registered by Bridge.provide on the MCU side.elif cmd == "OFF": When OFF is received, passes False to turn the LED off.Code block: Scanning for the ESP32
async def find_esp32():
print("Scanning for ESP32...")
devices = await BleakScanner.discover(timeout=5.0)
for d in devices:
if d.name == ESP32_NAME:
print(f"Found: {d.name} ({d.address})")
return d.address
return None
Purpose: Scan nearby devices and find the target ESP32
BleakScanner.discover(timeout=5.0): Scans for 5 seconds and returns the list of discovered devices. await waits for the scan to finish.for d in devices: Iterates over the scan results.if d.name == ESP32_NAME: Matches by device name. The BLE broadcast name is exactly the name set by BLEDevice::init on the ESP32 side.return d.address: Returns the MAC address of the found device, which will be used for connection later.return None: Returns None if nothing is found within 5 seconds.Code block: Main flow
async def main():
address = await find_esp32()
if address is None:
print("ESP32 not found. Make sure it is flashed and powered on.")
return
async with BleakClient(address) as client:
print("Connected to ESP32")
await client.start_notify(CHAR_NOTIFY_UUID, status_handler)
print("Subscribed to ESP32 notifications, waiting for commands...")
while True:
await asyncio.sleep(1)
asyncio.run(main())
Purpose: Connect to the ESP32 and subscribe to notifications
async def main(): Defines the asynchronous main function.await find_esp32(): Calls the scan function to get the ESP32 address.if address is None: If not found, prints a message and returns immediately.async with BleakClient(address) as client: Establishes a BLE connection. async with automatically disconnects when exiting the block, avoiding resource leaks.await client.start_notify(CHAR_NOTIFY_UUID, status_handler): Subscribes to the notify characteristic. When the ESP32 calls notify(), status_handler will be triggered.while True: await asyncio.sleep(1): Keeps the main coroutine alive, waiting for commands from the ESP32. Without this loop, the program exits immediately and the connection drops.asyncio.run(main()): Starts the asynchronous event loop, the program entry point.Code block: Importing libraries and defining UUIDs
#include <BLEDevice.h>
#include <BLEUtils.h>
#include <BLEServer.h>
#define SERVICE_UUID "4fafc201-1fb5-459e-8fcc-c5c9c331914b"
#define CHAR_WRITE_UUID "beb5483e-36e1-4688-b7f5-ea07361b26a8"
#define CHAR_NOTIFY_UUID "beb5483e-36e1-4688-b7f5-ea07361b26a9"
Purpose: Import BLE libraries and define UUIDs
#include <BLEDevice.h>: The ESP32 BLE core library, providing classes such as BLEDevice and BLEServer.#include <BLEUtils.h>: Provides BLE utility classes.#include <BLEServer.h>: Provides BLEServer and BLECharacteristicCallbacks.SERVICE_UUID / CHAR_WRITE_UUID / CHAR_NOTIFY_UUID: These three UUIDs must match exactly with the UNO Q Linux side, otherwise the connection or subscription will fail.Code block: Declaring the notify characteristic pointer
BLECharacteristic *pNotifyChar = nullptr;
Purpose: Promote the notify characteristic to a global variable
setup() but also used in loop() to push notifications, it must be declared as a global variable.nullptr is good practice, avoiding an uninitialized pointer.Code block: Write callback
class MyCallbacks : public BLECharacteristicCallbacks {
void onWrite(BLECharacteristic *pCharacteristic) {
String value = pCharacteristic->getValue();
if (value.length() > 0) {
Serial.print("Received from UNO Q: ");
Serial.println(value.c_str());
if (value == "ON") {
digitalWrite(LED_PIN, HIGH);
} else if (value == "OFF") {
digitalWrite(LED_PIN, LOW);
}
}
}
};
Purpose: Handle commands written by the UNO Q
class MyCallbacks : public BLECharacteristicCallbacks: Inherits from the ESP32 BLE library's callback base class and overrides onWrite.onWrite(BLECharacteristic *pCharacteristic): Called when a client writes data to this characteristic.pCharacteristic->getValue(): Retrieves the written data, returning a String.value.length() > 0: Filters out empty writes to prevent false triggering.ON/OFF, while printing logs for debugging. This is the reverse channel — the UNO Q can also write commands to the ESP32.Code block: setup function
void setup() {
Serial.begin(115200);
pinMode(LED_PIN, OUTPUT);
digitalWrite(LED_PIN, LOW);
BLEDevice::init("ESP32-BLE-Server");
BLEServer *pServer = BLEDevice::createServer();
BLEService *pService = pServer->createService(SERVICE_UUID);
BLECharacteristic *pWriteChar = pService->createCharacteristic(
CHAR_WRITE_UUID,
BLECharacteristic::PROPERTY_WRITE
);
pWriteChar->setCallbacks(new MyCallbacks());
pNotifyChar = pService->createCharacteristic(
CHAR_NOTIFY_UUID,
BLECharacteristic::PROPERTY_READ | BLECharacteristic::PROPERTY_NOTIFY
);
pNotifyChar->setValue("OFF");
pService->start();
BLEAdvertising *pAdvertising = BLEDevice::getAdvertising();
pAdvertising->addServiceUUID(SERVICE_UUID);
pAdvertising->start();
}
Purpose: Initialize the BLE server
Serial.begin(115200):pinMode(LED_PIN, OUTPUT) / digitalWrite(LED_PIN, LOW): Initializes the ESP32 onboard LED pin.BLEDevice::init("ESP32-BLE-Server"): Initializes the BLE stack and sets the device name. This name is the target name matched during scanning on the UNO Q side.BLEDevice::createServer(): Creates a BLE server instance.pServer->createService(SERVICE_UUID): Creates a BLE service identified by the given UUID.pService->createCharacteristic(CHAR_WRITE_UUID, PROPERTY_WRITE): Creates a writable characteristic with the WRITE property, meaning clients can write to it.pWriteChar->setCallbacks(new MyCallbacks()): Binds the callback to the writable characteristic, triggering onWrite when data is written.pService->createCharacteristic(CHAR_NOTIFY_UUID, PROPERTY_READ | PROPERTY_NOTIFY): Creates a notify characteristic with the READ (readable) and NOTIFY (can push notifications) properties. NOTIFY is a prerequisite for start_notify to succeed.pNotifyChar->setValue("OFF"): Sets the initial value.pService->start(): Starts the service, making the characteristics effective.BLEAdvertising *pAdvertising = BLEDevice::getAdvertising(): Gets the advertising object.pAdvertising->addServiceUUID(SERVICE_UUID): Adds the service UUID to the advertising data, making it easier for clients to filter by service.pAdvertising->start(): Starts advertising so the UNO Q can scan for it.Code block: loop function
void loop() {
if (Serial.available()) {
String cmd = Serial.readStringUntil('\n');
cmd.trim();
if (cmd == "ON" || cmd == "OFF") {
pNotifyChar->setValue(cmd.c_str());
pNotifyChar->notify();
Serial.print("Notification sent: ");
Serial.println(cmd);
}
}
delay(50);
}
Purpose: Read serial input and send via BLE notification
Serial.available(): Checks whether there is data available on the serial port.Serial.readStringUntil('\n'): Reads a line of input, terminated by a newline character.cmd.trim(): Removes leading/trailing whitespace to avoid interference from \r.if (cmd == "ON" || cmd == "OFF"): Only processes valid commands; other input is ignored.pNotifyChar->setValue(cmd.c_str()): Updates the notify characteristic's value. setValue accepts either const char* or String.pNotifyChar->notify(): Pushes a notification to all subscribed clients (here, the UNO Q), triggering the other side's callback.delay(50): A slight delay to reduce CPU usage while keeping serial reading stable.Code block: Importing the Bridge library
#include "Arduino_RouterBridge.h"
Purpose: Import the UNO Q Bridge library
#include "Arduino_RouterBridge.h": Imports the UNO Q official Bridge library, providing functions like Bridge.begin(), Bridge.provide(), and Bridge.update(), used for RPC communication between the Linux side and the MCU side.Code block: Defining the LED control function
const int LED_PIN = 7;
void set_led_state(bool state) {
digitalWrite(LED_PIN, state ? HIGH : LOW);
}
Purpose: Define an LED control function callable from Linux
const int LED_PIN = 7: Defines the LED pin number. Change according to your actual wiring.void set_led_state(bool state): Receives a boolean state. The bool type lets Bridge correctly map Python's True/False.digitalWrite(LED_PIN, state ? HIGH : LOW): Ternary operator: if state is true, outputs HIGH (on), otherwise LOW (off).Code block: setup function
void setup() {
pinMode(LED_PIN, OUTPUT);
digitalWrite(LED_PIN, LOW);
Bridge.begin();
Bridge.provide("set_led_state", set_led_state);
}
Purpose: Initialize pins and Bridge
pinMode(LED_PIN, OUTPUT): Sets the LED pin to output mode.digitalWrite(LED_PIN, LOW): Initially off.Bridge.begin(): Initializes Bridge communication. Must be called before provide.Bridge.provide("set_led_state", set_led_state): Registers the set_led_state function with Bridge. The registered name "set_led_state" must exactly match Bridge.call("set_led_state", ...) on the Python side.Code block: loop function
void loop() {
Bridge.update();
}
Purpose: Continuously process Bridge messages
Bridge.update(): Must be called continuously inside loop() so that Bridge can promptly respond to calls from the Linux side. If this is empty, Bridge.call on the Python side will block forever.Open the Serial Monitor at 115200 and confirm you see BLE service started.
Make sure the compile passes and the upload finishes without errors.
On the UNO Q SSH terminal, activate the virtual environment and run:
python3 ble.py
After you see Subscribed to ESP32 notifications, the program is waiting for commands.
Type ON and press Enter. You should see:
Notification sent: ONReceived from ESP32: ONThen type OFF and the LED turns off.
You can manually confirm on the UNO Q terminal with bluetoothctl whether the Bluetooth adapter is powered on and whether ESP32-BLE-Server can be scanned:
bluetoothctl
power on
scan on
Type exit to quit.
Check whether the ESP32 is powered on and running correctly. The Serial Monitor should print BLE service started
Confirm the UNO Q Bluetooth adapter is powered on (you can verify with bluetoothctl and the power on command)
Check whether the UNO Q Bluetooth is discoverable. There are two ways:
sudo bluetoothctl
After entering the [bluetooth]# interactive terminal, type:
discoverable on
This enables discoverability and removes Bluetooth invisibility.
Second method: Enter the UNO Q system, click the Bluetooth icon in the top-right corner, select Adapter → Preferences → Visibility Setting, then choose Always Visible
Confirm the distance between the ESP32 and UNO Q is less than 10 meters, with no strong interference in between
Check whether the ESP32 is already occupied by another connection (a BLE server usually allows only one client at a time)
BLEDevice::init("ESP32-BLE-Server") on the ESP32 matches ESP32_NAME on the Python side exactly (including case and hyphens)pAdvertising->start() was successfully called on the ESP325.0 in BleakScanner.discover(timeout=5.0) to 10.0BLECharacteristic::PROPERTY_NOTIFYCHAR_NOTIFY_UUID on the Python side matches CHAR_NOTIFY_UUID on the ESP32 exactlystart_notify is called after a successful connectionLED_PIN on the UNO Q MCU matches your actual wiringdigitalWriteset_led_state)ModuleNotFoundError: No module named 'bleak': Run python3 -m pip install bleak, make sure it is installed in the current venvModuleNotFoundError: No module named 'arduino': Install the GitHub wheel file, see Section 2PermissionError or org.bluez.Error.NotPermitted: Run with sudo, or add the user to the bluetooth groupwhile True: await asyncio.sleep(1) is presentNewline (not No line ending)Serial.readStringUntil('\n') cannot read a complete commandcmd.trim() contains hidden charactersBLEDevice.h: No such file or directory: The board is set to UNO Q, switch to ESP32 Dev ModuleArduino_RouterBridge.h: No such file or directory: The board is set to ESP32, switch to Arduino UNO Qconversion from 'BLEScanResults*' to non-scalar type 'BLEScanResults': ESP32 BLE library 3.x returns a pointer, so you need to receive it with BLEScanResults*| Item | WiFi Approach | Bluetooth Approach |
|---|---|---|
| ESP32 role | HTTP client | BLE server |
| UNO Q Linux role | Flask server | BLE client |
| Dependency | flask | bleak |
| Communication | HTTP GET request | GATT notification |
| Router required | Yes, same LAN | No, point-to-point |
| Range | Longer (depends on WiFi coverage) | Shorter (about 10 meters) |
The MCU-side Sketch on the UNO Q is identical in both approaches. Only the Linux side swaps Flask for bleak — the architecture idea is the same.
Register more functions on the MCU side:
void set_pin_13(bool state) {
digitalWrite(13, state ? HIGH : LOW);
}
Bridge.provide("set_pin_13", set_pin_13);
Add branches in the Python notification callback:
elif cmd == "PIN13_ON":
Bridge.call("set_pin_13", True)
elif cmd == "PIN13_OFF":
Bridge.call("set_pin_13", False)
Then type PIN13_ON in the ESP32 Serial Monitor.
MCU side:
void set_led_brightness(int value) {
analogWrite(LED_PIN, value);
}
Bridge.provide("set_led_brightness", set_led_brightness);
Python side:
if cmd.startswith("BRIGHTNESS:"):
value = int(cmd.split(":")[1])
Bridge.call("set_led_brightness", value)
Then type BRIGHTNESS:128 in the ESP32 Serial Monitor.
Bridge.call again inside an MCU Bridge callback, as this may cause a deadlock.| File / Component | Location | Purpose |
|---|---|---|
| ble.py | UNO Q ~/my_flask_project/ | BLE client, receives ESP32 notifications and calls Bridge |
| MCU Sketch | Arduino IDE flashed to UNO Q | Registers the set_led_state Bridge function |
| ESP32 Sketch | Arduino IDE flashed to ESP32 | BLE server, sends control commands |