Loading files
This file is pinned to the selected release. Download the original to open it in your design tool.
5ec15a3a8This file is pinned to the selected release. Download the original to open it in your design tool.
#!/usr/bin/env python3
#
# This file is part of DroneBridge: https://github.com/DroneBridge/ESP32
#
# Copyright 2025 Wolfgang Christl
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
#
# requires: pip install bleak
# DESCRIPTION
# Creates a bridge between a DroneBridge BLE device and a local GCS listening on UDP 127.0.0.1:14551
# --- START THE GCS AND OPEN UDP 14550 BEFORE RUNNING THIS SCRIPT ----
import asyncio
import logging
import socket
import sys
from typing import Optional
from bleak import BleakClient, BleakScanner
from bleak.backends.characteristic import BleakGATTCharacteristic
from bleak.exc import BleakError
# --- Configuration ---
# Target BLE device name (Matches ESP-IDF ble_spp_server example)
DEVICE_NAME = "DroneBridge"
# Target UDP destination (where data from BLE should be sent) -> The GND Station listens to this port
UDP_TARGET_IP = "127.0.0.1"
UDP_TARGET_PORT = 14550
# Local UDP listening port (where data to be sent to BLE comes from)
UDP_LISTEN_IP = "127.0.0.1" # Listen on all interfaces
UDP_LISTEN_PORT = 14551 # Choose a free port
# UUIDs for the ESP-IDF SPP Server example
DB_BLE_SERVICE_UUID = "0000db32-0000-1000-8000-00805f9b34fb"
# Characteristic for sending data TO the ESP32 (Python Writes, ESP Receives)
DB_BLE_RX_CHAR_UUID = "0000db33-0000-1000-8000-00805f9b34fb"
# Characteristic for receiving data FROM the ESP32 (Python Reads via Notify, ESP Sends)
DB_BLE_TX_CHAR_UUID = "0000db34-0000-1000-8000-00805f9b34fb"
# ---------------------
logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(levelname)s - %(message)s')
logger = logging.getLogger(__name__)
# Queue to send data from UDP listener to BLE writer task
ble_write_queue = asyncio.Queue()
# Global reference to BLE client and write characteristic (for UDP Protocol)
ble_client_global: Optional[BleakClient] = None
write_characteristic_global: Optional[BleakGATTCharacteristic] = None
udp_transport_global: Optional[asyncio.DatagramTransport] = None
class UDPProtocol(asyncio.DatagramProtocol):
"""Handles receiving UDP datagrams and queuing them for BLE transmission."""
def connection_made(self, transport: asyncio.DatagramTransport):
"""Called when the UDP socket is set up."""
global udp_transport_global
self.transport = transport
udp_transport_global = transport # Store transport globally for sending
logger.info(f"UDP listener started on {UDP_LISTEN_IP}:{UDP_LISTEN_PORT}")
def datagram_received(self, data: bytes, addr):
"""Called when a UDP datagram is received."""
logger.debug(f"UDP received {len(data)} bytes from {addr}")
# Put the received data onto the queue to be sent via BLE
try:
ble_write_queue.put_nowait(data)
except asyncio.QueueFull:
logger.warning("BLE write queue is full, dropping UDP packet.")
def error_received(self, exc):
"""Called when a UDP send or receive operation raises an OSError."""
logger.error(f"UDP error: {exc}")
def connection_lost(self, exc):
"""Called when the UDP socket is closed."""
logger.info("UDP listener stopped.")
def ble_notification_handler(characteristic: BleakGATTCharacteristic, data: bytearray):
"""Handles data received from BLE notifications and sends it via UDP."""
logger.debug(f"BLE received {len(data)} bytes on handle {characteristic.handle} ({characteristic.uuid})")
if udp_transport_global:
try:
udp_target = (UDP_TARGET_IP, UDP_TARGET_PORT)
udp_transport_global.sendto(data, udp_target)
logger.debug(f"Sent {len(data)} bytes to UDP {udp_target}")
except Exception as e:
logger.error(f"Failed to send data via UDP: {e}")
else:
logger.warning("UDP transport not ready, dropping BLE packet.")
async def ble_writer_task():
"""Waits for data on the queue and writes it to the BLE characteristic."""
global ble_client_global, write_characteristic_global
while True:
try:
data_to_send = await ble_write_queue.get()
if ble_client_global and ble_client_global.is_connected and write_characteristic_global:
try:
# Write without response is often preferred for SPP throughput.
# The ESP characteristic has WRITE property. If write-without-response
# fails or causes issues, change response=True.
await ble_client_global.write_gatt_char(
write_characteristic_global,
data_to_send,
response=False # Set to True if write without response doesn't work reliably
)
logger.debug(f"Sent {len(data_to_send)} bytes to BLE {write_characteristic_global.uuid}")
except BleakError as e:
logger.error(f"Failed to write to BLE characteristic {write_characteristic_global.uuid}: {e}")
# Optional: Handle write errors (e.g., requeue, disconnect)
except Exception as e:
logger.error(f"Unexpected error writing to BLE characteristic {write_characteristic_global.uuid}: {e}")
else:
logger.warning("BLE not connected or characteristic not found, dropping packet.")
ble_write_queue.task_done() # Mark task as done for queue management
except asyncio.CancelledError:
logger.info("BLE writer task cancelled.")
break
except Exception as e:
logger.error(f"Error in BLE writer task: {e}")
await asyncio.sleep(1) # Avoid busy-looping on unexpected errors
async def main():
"""Main function to scan, connect, set up bridge, and run."""
global ble_client_global, write_characteristic_global
ble_client = None
udp_transport = None
udp_protocol = None
writer_task = None
tx_char = None # Define tx_char here for cleanup scope
while True: # Outer loop to handle reconnection
device_address = None
logger.info(f"Scanning for BLE device named '{DEVICE_NAME}'...")
try:
# Scan for the device
device = await BleakScanner.find_device_by_name(DEVICE_NAME, timeout=10.0)
if device:
device_address = device.address
logger.info(f"Found device '{DEVICE_NAME}' at address {device_address}")
else:
logger.warning(f"Device '{DEVICE_NAME}' not found. Retrying in 10 seconds...")
await asyncio.sleep(10)
continue # Restart scanning
# --- Setup UDP Listener ---
loop = asyncio.get_running_loop()
logger.info(f"Setting up UDP listener on {UDP_LISTEN_IP}:{UDP_LISTEN_PORT}")
try:
# Ensure previous transport is closed before creating a new one
if udp_transport and not udp_transport.is_closing():
udp_transport.close()
udp_transport = None
udp_transport, udp_protocol = await loop.create_datagram_endpoint(
lambda: UDPProtocol(),
local_addr=(UDP_LISTEN_IP, UDP_LISTEN_PORT),
family=socket.AF_INET # Use AF_INET6 for IPv6
)
except OSError as e:
logger.error(f"Failed to bind UDP listener to {UDP_LISTEN_IP}:{UDP_LISTEN_PORT}: {e}. "
f"Port might be in use. Retrying in 3 seconds...")
await asyncio.sleep(3)
continue # Restart scanning/setup
except Exception as e:
logger.error(f"Unexpected error setting up UDP: {e}. Retrying in 3 seconds...")
await asyncio.sleep(3)
continue # Restart scanning/setup
# --- Connect to BLE Device ---
disconnected_event = asyncio.Event()
def handle_disconnect(_: BleakClient):
global ble_client_global, write_characteristic_global, udp_transport_global
logger.warning(f"BLE device {DEVICE_NAME} disconnected.")
ble_client_global = None
write_characteristic_global = None
# Don't clear udp_transport_global here, let the main loop handle UDP cleanup if needed
# Signal the main loop to attempt reconnection
disconnected_event.set()
logger.info(f"Connecting to {device_address}...")
ble_client = BleakClient(device_address, disconnected_callback=handle_disconnect)
try:
await ble_client.connect()
if ble_client.is_connected:
logger.info(f"Successfully connected to BLE device {DEVICE_NAME}.")
ble_client_global = ble_client # Store globally
# --- Find Characteristics ---
logger.info(f"Looking for SPP Service: {DB_BLE_SERVICE_UUID}")
# Ensure services are discovered (should happen on connect, but check)
await ble_client.get_services()
service = ble_client.services.get_service(DB_BLE_SERVICE_UUID)
if not service:
logger.error(f"SPP Service {DB_BLE_SERVICE_UUID} not found on device.")
await ble_client.disconnect()
if udp_transport: udp_transport.close()
await asyncio.sleep(5)
continue # Restart process
logger.info(f"Looking for SPP Notify Characteristic (TX): {DB_BLE_TX_CHAR_UUID}")
tx_char = service.get_characteristic(DB_BLE_TX_CHAR_UUID)
if not tx_char or "notify" not in tx_char.properties:
logger.error(f"SPP Notify Characteristic {DB_BLE_TX_CHAR_UUID} (with Notify) not found.")
await ble_client.disconnect()
if udp_transport: udp_transport.close()
await asyncio.sleep(5)
continue # Restart process
logger.info(f"Looking for SPP Write Characteristic (RX): {DB_BLE_RX_CHAR_UUID}")
rx_char = service.get_characteristic(DB_BLE_RX_CHAR_UUID)
# Check for write or write-without-response
if not rx_char or not any(p in rx_char.properties for p in ["write", "write-without-response"]):
logger.error(f"SPP Write Characteristic {DB_BLE_RX_CHAR_UUID} (with Write) not found.")
await ble_client.disconnect()
if udp_transport: udp_transport.close()
await asyncio.sleep(5)
continue # Restart process
write_characteristic_global = rx_char # Store globally
logger.info("Found required BLE Service and Characteristics.")
# --- Start Services ---
# Start BLE writer task
writer_task = asyncio.create_task(ble_writer_task())
# Start BLE notifications
logger.info(f"Starting notifications on {tx_char.uuid}...")
await ble_client.start_notify(tx_char, ble_notification_handler)
logger.info("--- Bridge is running ---")
logger.info(f"BLE ({DEVICE_NAME}) <--> UDP ({UDP_LISTEN_IP}:{UDP_LISTEN_PORT} -> {UDP_TARGET_IP}:{UDP_TARGET_PORT})")
# Keep running until disconnected
await disconnected_event.wait()
logger.info("Disconnect event received, cleaning up for reconnection...")
else:
logger.error(f"Failed to connect to BLE device {DEVICE_NAME}.")
except BleakError as e:
logger.error(f"BleakError during BLE connection or setup: {e}")
except Exception as e:
logger.error(f"Error during BLE connection or setup: {e}", exc_info=True)
finally:
# --- Cleanup on disconnect or error before retrying ---
logger.info("Cleaning up resources before potential reconnection...")
if writer_task and not writer_task.done():
writer_task.cancel()
try:
await writer_task # Wait for task to finish cancellation
except asyncio.CancelledError:
pass # Expected
# Clear queues and globals related to BLE connection
while not ble_write_queue.empty():
try:
ble_write_queue.get_nowait()
ble_write_queue.task_done()
except asyncio.QueueEmpty:
break # Should not happen if not empty, but safety check
ble_client_global = None
write_characteristic_global = None
# Keep UDP transport alive unless explicitly stopped by error or KeyboardInterrupt
if ble_client and ble_client.is_connected:
try:
# Attempt to stop notifications gracefully if still connected and tx_char exists
if tx_char:
await ble_client.stop_notify(tx_char)
logger.info(f"Stopped notifications on {tx_char.uuid}")
except Exception as e:
logger.warning(f"Error stopping notifications: {e}")
try:
await ble_client.disconnect()
logger.info("BLE client disconnected.")
except Exception as e:
logger.warning(f"Error during BLE disconnect: {e}")
# Don't close UDP transport here, allow reconnection attempts
# It will be closed in the main exception handler or if a new one is created
logger.info("Waiting 5 seconds before attempting reconnection...")
await asyncio.sleep(5)
except Exception as e:
logger.critical(f"Unhandled exception in main loop: {e}", exc_info=True)
# Clean up UDP on critical errors before retrying
if udp_transport and not udp_transport.is_closing():
udp_transport.close()
udp_transport = None
logger.info("Waiting 10 seconds before retrying...")
await asyncio.sleep(10)
if __name__ == "__main__":
udp_transport_main = None # Keep track of transport for final cleanup
try:
# Assign transport created in main loop to this variable if needed
# This part is tricky as transport is created inside the loop.
# A better approach might involve passing the transport object around.
# For simplicity, we rely on the loop's cleanup and a final check.
asyncio.run(main())
except KeyboardInterrupt:
logger.info("Script interrupted by user.")
except Exception as e:
logger.critical(f"Fatal error preventing script execution: {e}", exc_info=True)
finally:
# Attempt final cleanup of global UDP transport if it exists
if udp_transport_global and not udp_transport_global.is_closing():
logger.info("Closing UDP transport on final exit.")
udp_transport_global.close()
logger.info("Script finished.")