Перевод статьи Bidirecional BLE Communication between Raspberry Pi and Raspberry Pi Pico WRui Santos & Sara Santos, Random Nerd Tutorials.

Научимся налаживать двустороннюю связь по Bluetooth Low Energy (BLE) между Raspberry Pi и Raspberry Pi Pico. Pico будет настроен как BLE-периферия с двумя характеристиками. Raspberry Pi будет читать и писать в эти характеристики — так и получится двусторонняя связь.

Для программы на Raspberry Pi (Python) используем библиотеку bleak, а для программы на Pico (MicroPython) — библиотеку aioble.

Это руководство основано на коде и наработках Edgardo Peregrino.

Если тема BLE для вас новая, рекомендуем сначала прочитать вводные материалы:

Что понадобится заранее#

  1. Raspberry Pi с поддержкой Bluetooth (например, RPi 4 или RPi 5).
  2. Pico W или Pico 2 W.
  3. На Pi: создать виртуальное окружение, установить в него библиотеку Bleak (и pigpio) — как описано в разделе «Preparing the Raspberry Pi» вводного руководства.
  4. Прошить на Pico W или Pico 2 W актуальную версию прошивки MicroPython.

Что нужно из железа#

  • Raspberry Pi (мы используем Raspberry Pi 5);
  • Raspberry Pi Pico W или Pico 2 W;
  • 1× светодиод;
  • 1× резистор 220 Ом (или близкого номинала);
  • макетная плата;
  • соединительные провода.

Схема подключения#

Схема та же, что и во вводном руководстве. Для Raspberry Pi нужен светодиод, подключённый к GPIO 17, — он даст визуальную обратную связь и покажет, что код работает. Pico ставится на макетную плату просто для удобства.

Схема: Raspberry Pi со светодиодом на GPIO 17 и Pico W на макетной плате

Светодиод на GPIO 17 у Raspberry Pi и Pico W на макетной плате.

Как работает двусторонняя связь?#

Raspberry Pi Pico настраивает GATT-сервис с двумя характеристиками:

  • rx_characteristic — характеристика, в которую пишет Pi. Pico читает её значение (получает данные от Pi).
  • tx_characteristic — характеристика, в которую пишет Pico. Pico отправляет данные в Pi.
Схема двусторонней связи с характеристиками rx и tx

Двусторонняя связь через характеристики rx и tx.

Код: Raspberry Pi Pico W (MicroPython)#

Начнём с кода для Pico. Скопируйте его в Thonny IDE. Понадобится установить пакет aioble: в Thonny откройте Tools → Manage Packages…, найдите aioble и нажмите Install.

# Rui Santos & Sara Santos - Random Nerd Tutorials
# Complete project details at https://RandomNerdTutorials.com/bidirecional-ble-communication-raspberry-pi-and-pico/

import asyncio
import aioble
import bluetooth

# Bluetooth configuration
_SERVICE_UUID = bluetooth.UUID(0x1848)
# Characteristic where the Raspberry Pi writes data TO the Pico (Inbox)
_PI_TO_PICO_CHARACTERISTIC_UUID = bluetooth.UUID(0x2A6E)
# Characteristic where the Pico writes data TO the Raspberry Pi (Outbox)
_PICO_TO_PI_CHARACTERISTIC_UUID = bluetooth.UUID(0x2A6F)
BLE_NAME = "Pico W Peripheral"

# Register GATT server
ble_service = aioble.Service(_SERVICE_UUID)

# Inbox: Raspberry Pi writes data to the Pico
rx_characteristic = aioble.Characteristic(
    ble_service, 
    _PI_TO_PICO_CHARACTERISTIC_UUID, 
    read=True, 
    write=True, 
    capture=True
)

# Outbox: Pico writes data to the Raspberry Pi
tx_characteristic = aioble.Characteristic(
    ble_service, 
    _PICO_TO_PI_CHARACTERISTIC_UUID, 
    read=True, 
    write=True, 
    notify=True, 
    capture=True
)

aioble.register_services(ble_service)

# Helper functions
def _encode_message(message):
    """Encode a message to bytes."""
    return message.encode('utf-8')

def _decode_message(data):
    """Decode a message from bytes."""
    return data.decode('utf-8')

# Task to handle sending data
async def send_task():
    message_count = 1
    while True:
        message = f" From {BLE_NAME}!: {message_count}"
        message_count += 1
        try:
            tx_characteristic.write(_encode_message(message), send_update=True)
            print(f"Sent: {message}")
        except Exception as e:
            print(f"Error while sending data: {e}")
       
        await asyncio.sleep_ms(1000)

# Task to handle receiving data
async def receive_task():
    last_data = None                     
    while True:
        try:
            data = rx_characteristic.read()
            
            if data and data != last_data:          
                decoded = _decode_message(data)
                print(f"Received: {decoded}")
                last_data = data                    
                
            await asyncio.sleep_ms(500)
            
        except Exception as e:
            print(f"Error receiving data: {e}")
            await asyncio.sleep_ms(500)

# Serially wait for connections
async def peripheral_task():
    # Show MAC address once at start
    ble = bluetooth.BLE()
    _, mac_address = ble.config('mac')
    formatted_mac = ':'.join('{:02X}'.format(b) for b in mac_address)
    print(f"Bluetooth MAC Address: {formatted_mac}")
   
    while True:
        try:
            async with await aioble.advertise(
                2000, name=BLE_NAME, services=[_SERVICE_UUID], appearance=768
            ) as connection:
                print("Connection from", connection.device)
                await connection.disconnected()
               
        except Exception as e:
            print("Error in peripheral_task:", e)
        finally:
            print(f"{BLE_NAME} disconnected")
            await asyncio.sleep_ms(100)

# Run all tasks
async def main():
    t1 = asyncio.create_task(send_task())
    t2 = asyncio.create_task(receive_task())
    t3 = asyncio.create_task(peripheral_task())
    await asyncio.gather(t1, t2, t3)

# Run the program
asyncio.run(main())

Мы используем библиотеку aioble, чтобы настроить Pico как BLE-периферию.

Как работает код#

Сначала импортируем библиотеки.

import asyncio
import aioble
import bluetooth

Задаём UUID для GATT-сервиса, для характеристики, куда будем писать данные, и для характеристики, откуда будем их читать. Также задаём имя устройства.

_SERVICE_UUID = bluetooth.UUID(0x1848)
_PI_TO_PICO_CHARACTERISTIC_UUID = bluetooth.UUID(0x2A6E)
_PICO_TO_PI_CHARACTERISTIC_UUID = bluetooth.UUID(0x2A6F)
BLE_NAME = "Pico W Peripheral"

Создаём сервис GATT-сервера по заданному UUID — он служит «контейнером» для характеристик.

ble_service = aioble.Service(_SERVICE_UUID)

Первая характеристика — та, в которую Raspberry Pi может писать (write=True); Pico может читать записанные данные (read=True), а capture=True заставляет Pico автоматически хранить последнее значение.

Вторая характеристика служит для отправки данных от Pico к Pi; notify=True позволяет Pico автоматически уведомлять Pi при появлении новых данных.

Функции _encode_message() и _decode_message() кодируют и декодируют сообщение в UTF-8.

Отправка данных. Асинхронная задача send_task() каждую секунду пишет сообщение со счётчиком в tx_characteristic.

tx_characteristic.write(_encode_message(message), send_update=True)

Приём данных. Задача receive_task() каждые 500 мс читает значение rx_characteristic и печатает его в Shell, только если оно изменилось.

data = rx_characteristic.read()

Pico как BLE-периферия. Задача peripheral_task() сначала печатает Bluetooth MAC-адрес, а затем начинает «рекламировать» (advertise) Pico как периферию с заданным ранее UUID сервиса.

Запуск задач. В main() создаём три асинхронные задачи (отправка, приём, периферия) и запускаем их через asyncio.gather().

Запуск кода на Pico#

Запустите код на Pico зелёной кнопкой Play в Thonny (или клавишей F5). После запуска отобразится Bluetooth MAC-адрес Pico — сохраните его, он понадобится в коде для Raspberry Pi.

Thonny IDE: вывод Bluetooth MAC-адреса Pico

MAC-адрес Pico в Thonny IDE.

Код: Raspberry Pi (Python)#

На Raspberry Pi создайте файл с кодом ниже, например pi_send_receive.py. Он должен лежать в той же папке, где вы создавали виртуальное окружение.

# Rui Santos & Sara Santos - Random Nerd Tutorials
# Complete project details at https://RandomNerdTutorials.com/bidirecional-ble-communication-raspberry-pi-and-pico/

import asyncio
from bleak import BleakClient, uuids
from gpiozero import LED

connected = False
led = LED(17)

# REPLACE WITH YOUR PICO MAC ADDRESS
pico_address = "FF:FF:FF:FF:FF:FF"

SERVICE_UUID = uuids.normalize_uuid_16(0x1848)
WRITE_CHARACTERISTIC_UUID = uuids.normalize_uuid_16(0x2A6E)
READ_CHARACTERISTIC_UUID = uuids.normalize_uuid_16(0x2A6F)

message_count = 0

async def send_data_task(client):
    global message_count
    while True:
        message_count += 1
        message = f"From central: {message_count}".encode("utf-8")
        
        try:
            if not client.is_connected:
                break
                
            await client.write_gatt_char(WRITE_CHARACTERISTIC_UUID, message)
            print(f"Central sent: {message.decode('utf-8')}")
        except Exception as e:
            print(f"Send error: {e}")
            break
        
        await asyncio.sleep(5)


async def receive_data_task(client):
    last_message = None
    while True:
        try:
            if not client.is_connected:
                break

            response = await client.read_gatt_char(READ_CHARACTERISTIC_UUID)

            if response:
                message = response.decode("utf-8")
                if message != last_message:
                    print(f"Central received: {message}")
                    last_message = message

        except Exception as e:
            print(f"Receive error: {e}")
            break
        
        await asyncio.sleep(2)


async def blink_task():
    global connected 
    print("blink task started")
    while True:
        led.toggle() 
        blink = 1000 if connected else 250
        await asyncio.sleep(blink / 1000)


async def connect_and_communicate(address):
    global connected
    print(f"Connecting to {address}...")

    blink_task_obj = asyncio.create_task(blink_task())

    while True:
        client = None
        try:
            client = BleakClient(address)
            await client.connect()
            connected = True
            print("Connected to Pico")

            tasks = [
                asyncio.create_task(send_data_task(client)),
                asyncio.create_task(receive_data_task(client))
            ]
            
            done, pending = await asyncio.wait(tasks, timeout=30, return_when=asyncio.FIRST_COMPLETED)
            
            for t in pending:
                t.cancel()

        except asyncio.CancelledError:
            raise
        except Exception as e:
            print(f"Connection failed or lost: {e}")
        finally:
            connected = False
            if client:
                try:
                    if client.is_connected:
                        await asyncio.wait_for(client.disconnect(), timeout=2.0)
                except:
                    pass
            print("Disconnected - trying to reconnect in 3 seconds...")
            await asyncio.sleep(3)


async def main():
    try:
        await connect_and_communicate(pico_address)
    except asyncio.CancelledError:
        print("\nCancelling tasks...")
    except KeyboardInterrupt:
        print("\nProgram stopped by user")
    except Exception as e:
        print(f"Unexpected error: {e}")
    finally:
        connected = False
        led.off()
        print("Cleanup completed.")


if __name__ == "__main__":
    try:
        asyncio.run(main())
    except KeyboardInterrupt:
        led.off()

Перед запуском впишите в эту строку Bluetooth MAC-адрес вашего Pico (вы получили его в предыдущем примере):

pico_address = "2C:CF:67:B6:D7:4C"

Как работает код#

Raspberry Pi подключается к Pico как BLE-клиент: пишет в характеристику, которую читает Pico, и читает характеристику, в которую пишет Pico.

Импортируем библиотеки — среди них bleak для Bluetooth.

import asyncio
from bleak import BleakClient, uuids
from gpiozero import LED

Булева переменная connected отслеживает наличие соединения, а GPIO 17 задаётся объектом LED для визуальной обратной связи.

UUID сервиса и характеристик должны совпадать с теми, что заданы на Pico.

SERVICE_UUID = uuids.normalize_uuid_16(0x1848)
WRITE_CHARACTERISTIC_UUID = uuids.normalize_uuid_16(0x2A6E)
READ_CHARACTERISTIC_UUID = uuids.normalize_uuid_16(0x2A6F)

Запись в характеристику (отправка). Функция send_data_task() каждые пять секунд пишет в rx_characteristic Pico.

await client.write_gatt_char(WRITE_CHARACTERISTIC_UUID, message)

Чтение характеристики (приём). Функция receive_data_task() каждые две секунды читает значение характеристики периферии.

response = await client.read_gatt_char(READ_CHARACTERISTIC_UUID)

Мигание светодиодом. Задача blink_task() работает параллельно: мигает светодиодом на GPIO 17 раз в секунду при наличии соединения и каждые 250 мс, если соединения нет.

Подключение к BLE-устройству. Функция connect_and_communicate() пытается подключиться к периферии с заданным MAC-адресом. Сначала запускается blink_task() для мгновенной обратной связи, затем в бесконечном цикле создаётся BLE-клиент и устанавливается соединение. После подключения запускаются задачи отправки и приёма; программа ждёт, пока одна из них завершится или пройдёт 30 секунд, после чего отменяет оставшуюся и готовится к переподключению. При ошибке соединение аккуратно закрывается, и через паузу — новая попытка.

Запуск Python-кода#

Создав файл pi_send_receive.py с вашим MAC-адресом Pico, запустите его на Raspberry Pi. Сначала убедитесь, что вы в созданном ранее виртуальном окружении; если вышли из него — активируйте снова:

source blue/bin/activate

Запуск программы:

python3 pi_send_receive.py

Демонстрация#

Код запустится и через несколько секунд подключится к Pico (убедитесь, что на Pico запущен код в Thonny). Raspberry Pi начнёт «отправлять» и «получать» данные, а светодиод на GPIO 17 — мигать раз в секунду.

Терминал Raspberry Pi с отправленными и полученными BLE-сообщениями

Отправка и приём BLE-сообщений на стороне Raspberry Pi.

В окне Thonny видно, как Pico «получает» и «отправляет» данные Pi.

Thonny IDE: Pico отправляет и получает BLE-сообщения

Отправка и приём сообщений на стороне Pico.

Raspberry Pi с мигающим светодиодом рядом с Pico W

Двусторонняя связь между Raspberry Pi и Pico W в работе.

Готово — вы наладили двустороннюю связь между платами Raspberry Pi и Pico.

Итог#

В этом руководстве вы научились налаживать двустороннюю связь по BLE между Raspberry Pi Pico и Raspberry Pi. Pico настраивает GATT-сервис с двумя характеристиками, а запись и чтение этих характеристик позволяют платам обмениваться данными. Мы просто шлём сообщение со счётчиком, но это легко переделать под передачу показаний датчиков, состояния GPIO и не только.

Отдельная благодарность читателю Edgardo Peregrino, который создал основу этого руководства и примеры кода.

Спасибо за чтение.