Compare commits

..

3 Commits

Author SHA1 Message Date
Gilles Boccon-Gibod 8be9f4cb0e add doc and fix types 2023-09-06 17:05:30 -07:00
Gilles Boccon-Gibod 1ea12b1bf7 rebase 2023-09-06 17:05:24 -07:00
Gilles Boccon-Gibod 65e6d68355 add tcp server 2023-09-06 16:49:21 -07:00
14 changed files with 164 additions and 1011 deletions
+1 -2
View File
@@ -105,7 +105,7 @@ class ServerBridge:
asyncio.create_task(self.pipe.l2cap_channel.disconnect())
def data_received(self, data):
print(color(f'<<< [TCP DATA]: {len(data)} bytes', 'blue'))
print(f'<<< Received on TCP: {len(data)}')
self.pipe.l2cap_channel.write(data)
try:
@@ -123,7 +123,6 @@ class ServerBridge:
await self.l2cap_channel.disconnect()
def on_l2cap_close(self):
print(color('*** L2CAP channel closed', 'red'))
self.l2cap_channel = None
if self.tcp_transport is not None:
self.tcp_transport.close()
+62 -2
View File
@@ -20,13 +20,29 @@ from __future__ import annotations
import logging
import asyncio
import enum
from typing import Callable, Dict, List, Optional, Tuple, Union, TYPE_CHECKING
from pyee import EventEmitter
from typing import Optional, Tuple, Callable, Dict, Union, TYPE_CHECKING
from . import core, l2cap
from .colors import color
from .core import BT_BR_EDR_TRANSPORT, InvalidStateError, ProtocolError
from .core import (
UUID,
BT_RFCOMM_PROTOCOL_ID,
BT_BR_EDR_TRANSPORT,
BT_L2CAP_PROTOCOL_ID,
InvalidStateError,
ProtocolError,
)
from .sdp import (
SDP_SERVICE_RECORD_HANDLE_ATTRIBUTE_ID,
SDP_BROWSE_GROUP_LIST_ATTRIBUTE_ID,
SDP_SERVICE_CLASS_ID_LIST_ATTRIBUTE_ID,
SDP_PROTOCOL_DESCRIPTOR_LIST_ATTRIBUTE_ID,
SDP_PUBLIC_BROWSE_ROOT,
DataElement,
ServiceAttribute,
)
if TYPE_CHECKING:
from bumble.device import Device, Connection
@@ -111,6 +127,50 @@ RFCOMM_DYNAMIC_CHANNEL_NUMBER_END = 30
# fmt: on
# -----------------------------------------------------------------------------
def make_service_sdp_records(
service_record_handle: int, channel: int, uuid: Optional[UUID] = None
) -> List[ServiceAttribute]:
"""
Create SDP records for an RFComm service given a channel number and an
optional UUID. A Service Class Attribute is included only if the UUID is not None.
"""
records = [
ServiceAttribute(
SDP_SERVICE_RECORD_HANDLE_ATTRIBUTE_ID,
DataElement.unsigned_integer_32(service_record_handle),
),
ServiceAttribute(
SDP_BROWSE_GROUP_LIST_ATTRIBUTE_ID,
DataElement.sequence([DataElement.uuid(SDP_PUBLIC_BROWSE_ROOT)]),
),
ServiceAttribute(
SDP_PROTOCOL_DESCRIPTOR_LIST_ATTRIBUTE_ID,
DataElement.sequence(
[
DataElement.sequence([DataElement.uuid(BT_L2CAP_PROTOCOL_ID)]),
DataElement.sequence(
[
DataElement.uuid(BT_RFCOMM_PROTOCOL_ID),
DataElement.unsigned_integer_8(channel),
]
),
]
),
),
]
if uuid:
records.append(
ServiceAttribute(
SDP_SERVICE_CLASS_ID_LIST_ATTRIBUTE_ID,
DataElement.sequence([DataElement.uuid(uuid)]),
)
)
return records
# -----------------------------------------------------------------------------
def compute_fcs(buffer: bytes) -> int:
result = 0xFF
+91 -60
View File
@@ -20,83 +20,109 @@ import sys
import os
import logging
from bumble.core import UUID
from bumble.device import Device
from bumble.transport import open_transport_or_link
from bumble.core import BT_L2CAP_PROTOCOL_ID, BT_RFCOMM_PROTOCOL_ID, UUID
from bumble.rfcomm import Server
from bumble.sdp import (
DataElement,
ServiceAttribute,
SDP_PUBLIC_BROWSE_ROOT,
SDP_SERVICE_RECORD_HANDLE_ATTRIBUTE_ID,
SDP_BROWSE_GROUP_LIST_ATTRIBUTE_ID,
SDP_SERVICE_CLASS_ID_LIST_ATTRIBUTE_ID,
SDP_PROTOCOL_DESCRIPTOR_LIST_ATTRIBUTE_ID,
)
from bumble.utils import AsyncRunner
from bumble.rfcomm import make_service_sdp_records
# -----------------------------------------------------------------------------
def sdp_records(channel):
def sdp_records(channel, uuid):
service_record_handle = 0x00010001
return {
0x00010001: [
ServiceAttribute(
SDP_SERVICE_RECORD_HANDLE_ATTRIBUTE_ID,
DataElement.unsigned_integer_32(0x00010001),
),
ServiceAttribute(
SDP_BROWSE_GROUP_LIST_ATTRIBUTE_ID,
DataElement.sequence([DataElement.uuid(SDP_PUBLIC_BROWSE_ROOT)]),
),
ServiceAttribute(
SDP_SERVICE_CLASS_ID_LIST_ATTRIBUTE_ID,
DataElement.sequence(
[DataElement.uuid(UUID('E6D55659-C8B4-4B85-96BB-B1143AF6D3AE'))]
),
),
ServiceAttribute(
SDP_PROTOCOL_DESCRIPTOR_LIST_ATTRIBUTE_ID,
DataElement.sequence(
[
DataElement.sequence([DataElement.uuid(BT_L2CAP_PROTOCOL_ID)]),
DataElement.sequence(
[
DataElement.uuid(BT_RFCOMM_PROTOCOL_ID),
DataElement.unsigned_integer_8(channel),
]
),
]
),
),
]
service_record_handle: make_service_sdp_records(
service_record_handle, channel, UUID(uuid)
)
}
# -----------------------------------------------------------------------------
def on_dlc(dlc):
print('*** DLC connected', dlc)
dlc.sink = lambda data: on_rfcomm_data_received(dlc, data)
def on_rfcomm_session(rfcomm_session, tcp_server):
print('*** RFComm session connected', rfcomm_session)
tcp_server.attach_session(rfcomm_session)
# -----------------------------------------------------------------------------
def on_rfcomm_data_received(dlc, data):
print(f'<<< Data received: {data.hex()}')
try:
message = data.decode('utf-8')
print(f'<<< Message = {message}')
except Exception:
pass
class TcpServerProtocol(asyncio.Protocol):
def __init__(self, server):
self.server = server
# Echo everything back
dlc.write(data)
def connection_made(self, transport):
peer_name = transport.get_extra_info('peer_name')
print(f'<<< TCP Server: connection from {peer_name}')
if self.server:
self.server.tcp_transport = transport
else:
transport.close()
def connection_lost(self, exc):
print('<<< TCP Server: connection lost')
if self.server:
self.server.tcp_transport = None
def data_received(self, data):
print(f'<<< TCP Server: data received: {len(data)} bytes - {data.hex()}')
if self.server:
self.server.tcp_data_received(data)
# -----------------------------------------------------------------------------
class TcpServer:
def __init__(self, port):
self.rfcomm_session = None
self.tcp_transport = None
AsyncRunner.spawn(self.run(port))
def attach_session(self, rfcomm_session):
if self.rfcomm_session:
self.rfcomm_session.sink = None
self.rfcomm_session = rfcomm_session
rfcomm_session.sink = self.rfcomm_data_received
def rfcomm_data_received(self, data):
print(f'<<< RFCOMM Data: {data.hex()}')
if self.tcp_transport:
self.tcp_transport.write(data)
else:
print('!!! no TCP connection, dropping data')
def tcp_data_received(self, data):
if self.rfcomm_session:
self.rfcomm_session.write(data)
else:
print('!!! no RFComm session, dropping data')
async def run(self, port):
print(f'$$$ Starting TCP server on port {port}')
server = await asyncio.get_running_loop().create_server(
lambda: TcpServerProtocol(self), '127.0.0.1', port
)
async with server:
await server.serve_forever()
# -----------------------------------------------------------------------------
async def main():
if len(sys.argv) < 3:
print('Usage: run_rfcomm_server.py <device-config> <transport-spec>')
print('example: run_rfcomm_server.py classic2.json usb:04b4:f901')
if len(sys.argv) < 4:
print(
'Usage: run_rfcomm_server.py <device-config> <transport-spec> '
'<tcp-port> [<uuid>]'
)
print('example: run_rfcomm_server.py classic2.json usb:0 8888')
return
tcp_port = int(sys.argv[3])
if len(sys.argv) >= 5:
uuid = sys.argv[4]
else:
uuid = 'E6D55659-C8B4-4B85-96BB-B1143AF6D3AE'
print('<<< connecting to HCI...')
async with await open_transport_or_link(sys.argv[2]) as (hci_source, hci_sink):
print('<<< connected')
@@ -105,15 +131,20 @@ async def main():
device = Device.from_config_file_with_hci(sys.argv[1], hci_source, hci_sink)
device.classic_enabled = True
# Create and register a server
# Create a TCP server
tcp_server = TcpServer(tcp_port)
# Create and register an RFComm server
rfcomm_server = Server(device)
# Listen for incoming DLC connections
channel_number = rfcomm_server.listen(on_dlc)
print(f'### Listening for connection on channel {channel_number}')
channel_number = rfcomm_server.listen(
lambda session: on_rfcomm_session(session, tcp_server)
)
print(f'### Listening for RFComm connections on channel {channel_number}')
# Setup the SDP to advertise this channel
device.sdp_service_records = sdp_records(channel_number)
device.sdp_service_records = sdp_records(channel_number, uuid)
# Start the controller
await device.power_on()
-1
View File
@@ -138,7 +138,6 @@ dependencies = [
"clap 4.4.1",
"directories",
"env_logger",
"futures",
"hex",
"itertools",
"lazy_static",
+5 -6
View File
@@ -15,7 +15,7 @@ rust-version = "1.70.0"
[dependencies]
pyo3 = { version = "0.18.3", features = ["macros"] }
pyo3-asyncio = { version = "0.18.0", features = ["tokio-runtime"] }
tokio = { version = "1.28.2", features = ["macros", "signal"] }
tokio = { version = "1.28.2" }
nom = "7.1.3"
strum = "0.25.0"
strum_macros = "0.25.0"
@@ -28,12 +28,11 @@ thiserror = "1.0.41"
anyhow = { version = "1.0.71", optional = true }
clap = { version = "4.3.3", features = ["derive"], optional = true }
directories = { version = "5.0.1", optional = true }
env_logger = { version = "0.10.0", optional = true }
futures = { version = "0.3.28", optional = true }
log = { version = "0.4.19", optional = true }
owo-colors = { version = "3.5.0", optional = true }
reqwest = { version = "0.11.20", features = ["blocking"], optional = true }
rusb = { version = "0.9.2", optional = true }
log = { version = "0.4.19", optional = true }
env_logger = { version = "0.10.0", optional = true }
[dev-dependencies]
tokio = { version = "1.28.2", features = ["full"] }
@@ -73,5 +72,5 @@ anyhow = ["pyo3/anyhow"]
pyo3-asyncio-attributes = ["pyo3-asyncio/attributes"]
bumble-codegen = ["dep:anyhow"]
# separate feature for CLI so that dependencies don't spend time building these
bumble-tools = ["dep:clap", "anyhow", "dep:anyhow", "dep:directories", "pyo3-asyncio-attributes", "dep:owo-colors", "dep:reqwest", "dep:rusb", "dep:log", "dep:env_logger", "dep:futures"]
default = []
bumble-tools = ["dep:clap", "anyhow", "dep:anyhow", "dep:directories", "pyo3-asyncio-attributes", "dep:owo-colors", "dep:reqwest", "dep:rusb", "dep:log", "dep:env_logger"]
default = []
-191
View File
@@ -1,191 +0,0 @@
// Copyright 2023 Google LLC
//
// 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.
/// L2CAP CoC client bridge: connects to a BLE device, then waits for an inbound
/// TCP connection on a specified port number. When a TCP client connects, an
/// L2CAP CoC channel connection to the BLE device is established, and the data
/// is bridged in both directions, with flow control.
/// When the TCP connection is closed by the client, the L2CAP CoC channel is
/// disconnected, but the connection to the BLE device remains, ready for a new
/// TCP client to connect.
/// When the L2CAP CoC channel is closed, the TCP connection is closed as well.
use crate::cli::l2cap::{
proxy_l2cap_rx_to_tcp_tx, proxy_tcp_rx_to_l2cap_tx, run_future_with_current_task_locals,
BridgeData,
};
use bumble::wrapper::{
device::{Connection, Device},
hci::HciConstant,
};
use futures::executor::block_on;
use owo_colors::OwoColorize;
use pyo3::{PyResult, Python};
use std::{net::SocketAddr, sync::Arc};
use tokio::{
join,
net::{TcpListener, TcpStream},
sync::{mpsc, Mutex},
};
pub struct Args {
pub psm: u16,
pub max_credits: Option<u16>,
pub mtu: Option<u16>,
pub mps: Option<u16>,
pub bluetooth_address: String,
pub tcp_host: String,
pub tcp_port: u16,
}
pub async fn start(args: &Args, device: &mut Device) -> PyResult<()> {
println!(
"{}",
format!("### Connecting to {}...", args.bluetooth_address).yellow()
);
let mut ble_connection = device.connect(&args.bluetooth_address).await?;
ble_connection.on_disconnection(|_py, reason| {
let disconnection_info = match HciConstant::error_name(reason) {
Ok(info_string) => info_string,
Err(py_err) => format!("failed to get disconnection error name ({})", py_err),
};
println!(
"{} {}",
"@@@ Bluetooth disconnection: ".red(),
disconnection_info,
);
Ok(())
})?;
// Start the TCP server.
let listener = TcpListener::bind(format!("{}:{}", args.tcp_host, args.tcp_port))
.await
.expect("failed to bind tcp to address");
println!(
"{}",
format!(
"### Listening for TCP connections on port {}",
args.tcp_port
)
.magenta()
);
let psm = args.psm;
let max_credits = args.max_credits;
let mtu = args.mtu;
let mps = args.mps;
let ble_connection = Arc::new(Mutex::new(ble_connection));
// Ensure Python event loop is available to l2cap `disconnect`
let _ = run_future_with_current_task_locals(async move {
while let Ok((tcp_stream, addr)) = listener.accept().await {
let ble_connection = ble_connection.clone();
let _ = run_future_with_current_task_locals(proxy_data_between_tcp_and_l2cap(
ble_connection,
tcp_stream,
addr,
psm,
max_credits,
mtu,
mps,
));
}
Ok(())
});
Ok(())
}
async fn proxy_data_between_tcp_and_l2cap(
ble_connection: Arc<Mutex<Connection>>,
tcp_stream: TcpStream,
addr: SocketAddr,
psm: u16,
max_credits: Option<u16>,
mtu: Option<u16>,
mps: Option<u16>,
) -> PyResult<()> {
println!("{}", format!("<<< TCP connection from {}", addr).magenta());
println!(
"{}",
format!(">>> Opening L2CAP channel on PSM = {}", psm).yellow()
);
let mut l2cap_channel = match ble_connection
.lock()
.await
.open_l2cap_channel(psm, max_credits, mtu, mps)
.await
{
Ok(channel) => channel,
Err(e) => {
println!("{}", format!("!!! Connection failed: {e}").red());
// TCP stream will get dropped after returning, automatically shutting it down.
return Err(e);
}
};
let channel_info = l2cap_channel
.debug_string()
.unwrap_or_else(|e| format!("failed to get l2cap channel info ({e})"));
println!("{}{}", "*** L2CAP channel: ".cyan(), channel_info);
let (l2cap_to_tcp_tx, l2cap_to_tcp_rx) = mpsc::channel::<BridgeData>(10);
// Set l2cap callback (`set_sink`) for when data is received.
let l2cap_to_tcp_tx_clone = l2cap_to_tcp_tx.clone();
l2cap_channel
.set_sink(move |_py, sdu| {
block_on(l2cap_to_tcp_tx_clone.send(BridgeData::Data(sdu.into())))
.expect("failed to channel data to tcp");
Ok(())
})
.expect("failed to set sink for l2cap connection");
// Set l2cap callback for when the channel is closed.
l2cap_channel
.on_close(move |_py| {
println!("{}", "*** L2CAP channel closed".red());
block_on(l2cap_to_tcp_tx.send(BridgeData::CloseSignal))
.expect("failed to channel close signal to tcp");
Ok(())
})
.expect("failed to set on_close callback for l2cap channel");
let l2cap_channel = Arc::new(Mutex::new(Some(l2cap_channel)));
let (tcp_reader, tcp_writer) = tcp_stream.into_split();
// Do tcp stuff when something happens on the l2cap channel.
let handle_l2cap_data_future =
proxy_l2cap_rx_to_tcp_tx(l2cap_to_tcp_rx, tcp_writer, l2cap_channel.clone());
// Do l2cap stuff when something happens on tcp.
let handle_tcp_data_future = proxy_tcp_rx_to_l2cap_tx(tcp_reader, l2cap_channel.clone(), true);
let (handle_l2cap_result, handle_tcp_result) =
join!(handle_l2cap_data_future, handle_tcp_data_future);
if let Err(e) = handle_l2cap_result {
println!("!!! Error: {e}");
}
if let Err(e) = handle_tcp_result {
println!("!!! Error: {e}");
}
Python::with_gil(|_| {
// Must hold GIL at least once while/after dropping for Python heap object to ensure
// de-allocation.
drop(l2cap_channel);
});
Ok(())
}
-190
View File
@@ -1,190 +0,0 @@
// Copyright 2023 Google LLC
//
// 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.
//! Rust version of the Python `l2cap_bridge.py` found under the `apps` folder.
use crate::L2cap;
use anyhow::anyhow;
use bumble::wrapper::{device::Device, l2cap::LeConnectionOrientedChannel, transport::Transport};
use owo_colors::{colors::css::Orange, OwoColorize};
use pyo3::{PyObject, PyResult, Python};
use std::{future::Future, path::PathBuf, sync::Arc};
use tokio::{
io::{AsyncReadExt, AsyncWriteExt},
net::tcp::{OwnedReadHalf, OwnedWriteHalf},
sync::{mpsc::Receiver, Mutex},
};
mod client_bridge;
mod server_bridge;
pub(crate) async fn run(
command: L2cap,
device_config: PathBuf,
transport: String,
psm: u16,
max_credits: Option<u16>,
mtu: Option<u16>,
mps: Option<u16>,
) -> PyResult<()> {
println!("<<< connecting to HCI...");
let transport = Transport::open(transport).await?;
println!("<<< connected");
let mut device =
Device::from_config_file_with_hci(&device_config, transport.source()?, transport.sink()?)?;
device.power_on().await?;
match command {
L2cap::Server { tcp_host, tcp_port } => {
let args = server_bridge::Args {
psm,
max_credits,
mtu,
mps,
tcp_host,
tcp_port,
};
server_bridge::start(&args, &mut device).await?
}
L2cap::Client {
bluetooth_address,
tcp_host,
tcp_port,
} => {
let args = client_bridge::Args {
psm,
max_credits,
mtu,
mps,
bluetooth_address,
tcp_host,
tcp_port,
};
client_bridge::start(&args, &mut device).await?
}
};
// wait until user kills the process
tokio::signal::ctrl_c().await?;
Ok(())
}
/// Used for channeling data from Python callbacks to a Rust consumer.
enum BridgeData {
Data(Vec<u8>),
CloseSignal,
}
async fn proxy_l2cap_rx_to_tcp_tx(
mut l2cap_data_receiver: Receiver<BridgeData>,
mut tcp_writer: OwnedWriteHalf,
l2cap_channel: Arc<Mutex<Option<LeConnectionOrientedChannel>>>,
) -> anyhow::Result<()> {
while let Some(bridge_data) = l2cap_data_receiver.recv().await {
match bridge_data {
BridgeData::Data(sdu) => {
println!("{}", format!("<<< [L2CAP SDU]: {} bytes", sdu.len()).cyan());
tcp_writer
.write_all(sdu.as_ref())
.await
.map_err(|_| anyhow!("Failed to write to tcp stream"))?;
tcp_writer
.flush()
.await
.map_err(|_| anyhow!("Failed to flush tcp stream"))?;
}
BridgeData::CloseSignal => {
l2cap_channel.lock().await.take();
tcp_writer
.shutdown()
.await
.map_err(|_| anyhow!("Failed to shut down write half of tcp stream"))?;
return Ok(());
}
}
}
Ok(())
}
async fn proxy_tcp_rx_to_l2cap_tx(
mut tcp_reader: OwnedReadHalf,
l2cap_channel: Arc<Mutex<Option<LeConnectionOrientedChannel>>>,
drain_l2cap_after_write: bool,
) -> PyResult<()> {
let mut buf = [0; 4096];
loop {
match tcp_reader.read(&mut buf).await {
Ok(len) => {
if len == 0 {
println!("{}", "!!! End of stream".fg::<Orange>());
if let Some(mut channel) = l2cap_channel.lock().await.take() {
channel.disconnect().await.map_err(|e| {
eprintln!("Failed to call disconnect on l2cap channel: {e}");
e
})?;
}
return Ok(());
}
println!("{}", format!("<<< [TCP DATA]: {len} bytes").blue());
match l2cap_channel.lock().await.as_mut() {
None => {
println!("{}", "!!! L2CAP channel not connected, dropping".red());
return Ok(());
}
Some(channel) => {
channel.write(&buf[..len])?;
if drain_l2cap_after_write {
channel.drain().await?;
}
}
}
}
Err(e) => {
println!("{}", format!("!!! TCP connection lost: {}", e).red());
if let Some(mut channel) = l2cap_channel.lock().await.take() {
let _ = channel.disconnect().await.map_err(|e| {
eprintln!("Failed to call disconnect on l2cap channel: {e}");
});
}
return Err(e.into());
}
}
}
}
/// Copies the current thread's TaskLocals into a Python "awaitable" and encapsulates it in a Rust
/// future, running it as a Python Task.
/// `TaskLocals` stores the current event loop, and allows the user to copy the current Python
/// context if necessary. In this case, the python event loop is used when calling `disconnect` on
/// an l2cap connection, or else the call will fail.
pub fn run_future_with_current_task_locals<F>(
fut: F,
) -> PyResult<impl Future<Output = PyResult<PyObject>> + Send>
where
F: Future<Output = PyResult<()>> + Send + 'static,
{
Python::with_gil(|py| {
let locals = pyo3_asyncio::tokio::get_current_locals(py)?;
let future = pyo3_asyncio::tokio::scope(locals.clone(), fut);
pyo3_asyncio::tokio::future_into_py_with_locals(py, locals, future)
.and_then(pyo3_asyncio::tokio::into_future)
})
}
-205
View File
@@ -1,205 +0,0 @@
// Copyright 2023 Google LLC
//
// 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.
/// L2CAP CoC server bridge: waits for a peer to connect an L2CAP CoC channel
/// on a specified PSM. When the connection is made, the bridge connects a TCP
/// socket to a remote host and bridges the data in both directions, with flow
/// control.
/// When the L2CAP CoC channel is closed, the bridge disconnects the TCP socket
/// and waits for a new L2CAP CoC channel to be connected.
/// When the TCP connection is closed by the TCP server, the L2CAP connection is closed as well.
use crate::cli::l2cap::{
proxy_l2cap_rx_to_tcp_tx, proxy_tcp_rx_to_l2cap_tx, run_future_with_current_task_locals,
BridgeData,
};
use bumble::wrapper::{device::Device, hci::HciConstant, l2cap::LeConnectionOrientedChannel};
use futures::executor::block_on;
use owo_colors::OwoColorize;
use pyo3::{PyResult, Python};
use std::{sync::Arc, time::Duration};
use tokio::{
join,
net::TcpStream,
select,
sync::{mpsc, Mutex},
};
pub struct Args {
pub psm: u16,
pub max_credits: Option<u16>,
pub mtu: Option<u16>,
pub mps: Option<u16>,
pub tcp_host: String,
pub tcp_port: u16,
}
pub async fn start(args: &Args, device: &mut Device) -> PyResult<()> {
let host = args.tcp_host.clone();
let port = args.tcp_port;
device.register_l2cap_channel_server(
args.psm,
move |_py, l2cap_channel| {
let channel_info = l2cap_channel
.debug_string()
.unwrap_or_else(|e| format!("failed to get l2cap channel info ({e})"));
println!("{} {channel_info}", "*** L2CAP channel:".cyan());
let host = host.clone();
// Ensure Python event loop is available to l2cap `disconnect`
let _ = run_future_with_current_task_locals(proxy_data_between_l2cap_and_tcp(
l2cap_channel,
host,
port,
));
Ok(())
},
args.max_credits,
args.mtu,
args.mps,
)?;
println!(
"{}",
format!("### Listening for CoC connection on PSM {}", args.psm).yellow()
);
device.on_connection(|_py, mut connection| {
let connection_info = connection
.debug_string()
.unwrap_or_else(|e| format!("failed to get connection info ({e})"));
println!(
"{} {}",
"@@@ Bluetooth connection: ".green(),
connection_info,
);
connection.on_disconnection(|_py, reason| {
let disconnection_info = match HciConstant::error_name(reason) {
Ok(info_string) => info_string,
Err(py_err) => format!("failed to get disconnection error name ({})", py_err),
};
println!(
"{} {}",
"@@@ Bluetooth disconnection: ".red(),
disconnection_info,
);
Ok(())
})?;
Ok(())
})?;
device.start_advertising(false).await?;
Ok(())
}
async fn proxy_data_between_l2cap_and_tcp(
mut l2cap_channel: LeConnectionOrientedChannel,
tcp_host: String,
tcp_port: u16,
) -> PyResult<()> {
let (l2cap_to_tcp_tx, mut l2cap_to_tcp_rx) = mpsc::channel::<BridgeData>(10);
// Set callback (`set_sink`) for when l2cap data is received.
let l2cap_to_tcp_tx_clone = l2cap_to_tcp_tx.clone();
l2cap_channel
.set_sink(move |_py, sdu| {
block_on(l2cap_to_tcp_tx_clone.send(BridgeData::Data(sdu.into())))
.expect("failed to channel data to tcp");
Ok(())
})
.expect("failed to set sink for l2cap connection");
// Set l2cap callback for when the channel is closed.
l2cap_channel
.on_close(move |_py| {
println!("{}", "*** L2CAP channel closed".red());
block_on(l2cap_to_tcp_tx.send(BridgeData::CloseSignal))
.expect("failed to channel close signal to tcp");
Ok(())
})
.expect("failed to set on_close callback for l2cap channel");
println!(
"{}",
format!("### Connecting to TCP {tcp_host}:{tcp_port}...").yellow()
);
let l2cap_channel = Arc::new(Mutex::new(Some(l2cap_channel)));
let tcp_stream = match TcpStream::connect(format!("{tcp_host}:{tcp_port}")).await {
Ok(stream) => {
println!("{}", "### Connected".green());
Some(stream)
}
Err(err) => {
println!("{}", format!("!!! Connection failed: {err}").red());
if let Some(mut channel) = l2cap_channel.lock().await.take() {
// Bumble might enter an invalid state if disconnection request is received from
// l2cap client before receiving a disconnection response from the same client,
// blocking this async call from returning.
// See: https://github.com/google/bumble/issues/257
select! {
res = channel.disconnect() => {
let _ = res.map_err(|e| eprintln!("Failed to call disconnect on l2cap channel: {e}"));
},
_ = tokio::time::sleep(Duration::from_secs(1)) => eprintln!("Timed out while calling disconnect on l2cap channel."),
}
}
None
}
};
match tcp_stream {
None => {
while let Some(bridge_data) = l2cap_to_tcp_rx.recv().await {
match bridge_data {
BridgeData::Data(sdu) => {
println!("{}", format!("<<< [L2CAP SDU]: {} bytes", sdu.len()).cyan());
println!("{}", "!!! TCP socket not open, dropping".red())
}
BridgeData::CloseSignal => break,
}
}
}
Some(tcp_stream) => {
let (tcp_reader, tcp_writer) = tcp_stream.into_split();
// Do tcp stuff when something happens on the l2cap channel.
let handle_l2cap_data_future =
proxy_l2cap_rx_to_tcp_tx(l2cap_to_tcp_rx, tcp_writer, l2cap_channel.clone());
// Do l2cap stuff when something happens on tcp.
let handle_tcp_data_future =
proxy_tcp_rx_to_l2cap_tx(tcp_reader, l2cap_channel.clone(), false);
let (handle_l2cap_result, handle_tcp_result) =
join!(handle_l2cap_data_future, handle_tcp_data_future);
if let Err(e) = handle_l2cap_result {
println!("!!! Error: {e}");
}
if let Err(e) = handle_tcp_result {
println!("!!! Error: {e}");
}
}
};
Python::with_gil(|_| {
// Must hold GIL at least once while/after dropping for Python heap object to ensure
// de-allocation.
drop(l2cap_channel);
});
Ok(())
}
-2
View File
@@ -15,5 +15,3 @@
pub(crate) mod firmware;
pub(crate) mod usb;
pub(crate) mod l2cap;
-92
View File
@@ -49,26 +49,6 @@ async fn main() -> PyResult<()> {
Realtek::Parse { firmware_path } => cli::firmware::rtk::parse(&firmware_path)?,
},
},
Subcommand::L2cap {
subcommand,
device_config,
transport,
psm,
l2cap_coc_max_credits,
l2cap_coc_mtu,
l2cap_coc_mps,
} => {
cli::l2cap::run(
subcommand,
device_config,
transport,
psm,
l2cap_coc_max_credits,
l2cap_coc_mtu,
l2cap_coc_mps,
)
.await?
}
Subcommand::Usb { subcommand } => match subcommand {
Usb::Probe(probe) => cli::usb::probe(probe.verbose)?,
},
@@ -90,46 +70,6 @@ enum Subcommand {
#[clap(subcommand)]
subcommand: Firmware,
},
/// L2cap client/server operations
L2cap {
#[command(subcommand)]
subcommand: L2cap,
/// Device configuration file.
///
/// See, for instance, `examples/device1.json` in the Python project.
#[arg(long)]
device_config: path::PathBuf,
/// Bumble transport spec.
///
/// <https://google.github.io/bumble/transports/index.html>
#[arg(long)]
transport: String,
/// PSM for L2CAP Connection-oriented Channel.
///
/// Must be in the range [0, 65535].
#[arg(long)]
psm: u16,
/// Maximum L2CAP CoC Credits. When not specified, lets Bumble set the default.
///
/// Must be in the range [1, 65535].
#[arg(long, value_parser = clap::value_parser!(u16).range(1..))]
l2cap_coc_max_credits: Option<u16>,
/// L2CAP CoC MTU. When not specified, lets Bumble set the default.
///
/// Must be in the range [23, 65535].
#[arg(long, value_parser = clap::value_parser!(u16).range(23..))]
l2cap_coc_mtu: Option<u16>,
/// L2CAP CoC MPS. When not specified, lets Bumble set the default.
///
/// Must be in the range [23, 65535].
#[arg(long, value_parser = clap::value_parser!(u16).range(23..))]
l2cap_coc_mps: Option<u16>,
},
/// USB operations
Usb {
#[clap(subcommand)]
@@ -225,38 +165,6 @@ impl fmt::Display for Source {
}
}
#[derive(clap::Subcommand, Debug, Clone)]
enum L2cap {
/// Starts an L2CAP server
Server {
/// TCP host that the l2cap server will connect to.
/// Data is bridged like so:
/// TCP server <-> (TCP client / **L2CAP server**) <-> (L2CAP client / TCP server) <-> TCP client
#[arg(long, default_value = "localhost")]
tcp_host: String,
/// TCP port that the server will connect to.
///
/// Must be in the range [1, 65535].
#[arg(long, default_value_t = 9544)]
tcp_port: u16,
},
/// Starts an L2CAP client
Client {
/// L2cap server address that this l2cap client will connect to.
bluetooth_address: String,
/// TCP host that the l2cap client will bind to and listen for incoming TCP connections.
/// Data is bridged like so:
/// TCP client <-> (TCP server / **L2CAP client**) <-> (L2CAP server / TCP client) <-> TCP server
#[arg(long, default_value = "localhost")]
tcp_host: String,
/// TCP port that the client will connect to.
///
/// Must be in the range [1, 65535].
#[arg(long, default_value_t = 9543)]
tcp_port: u16,
},
}
#[derive(clap::Subcommand, Debug, Clone)]
enum Usb {
/// Probe the USB bus for Bluetooth devices
+3 -118
View File
@@ -19,17 +19,16 @@ use crate::{
wrapper::{
core::AdvertisingData,
gatt_client::{ProfileServiceProxy, ServiceProxy},
hci::{Address, HciErrorCode},
hci::Address,
host::Host,
l2cap::LeConnectionOrientedChannel,
transport::{Sink, Source},
ClosureCallback, PyDictExt, PyObjectExt,
ClosureCallback, PyObjectExt,
},
};
use pyo3::{
intern,
types::{PyDict, PyModule},
IntoPy, PyObject, PyResult, Python, ToPyObject,
PyObject, PyResult, Python, ToPyObject,
};
use pyo3_asyncio::tokio::into_future;
use std::path;
@@ -88,22 +87,6 @@ impl Device {
.map(Connection)
}
/// Register a callback to be called for each incoming connection.
pub fn on_connection(
&mut self,
callback: impl Fn(Python, Connection) -> PyResult<()> + Send + 'static,
) -> PyResult<()> {
let boxed = ClosureCallback::new(move |py, args, _kwargs| {
callback(py, Connection(args.get_item(0)?.into()))
});
Python::with_gil(|py| {
self.0
.call_method1(py, intern!(py, "add_listener"), ("connection", boxed))
})
.map(|_| ())
}
/// Start scanning
pub async fn start_scanning(&self, filter_duplicates: bool) -> PyResult<()> {
Python::with_gil(|py| {
@@ -178,109 +161,11 @@ impl Device {
.await
.map(|_| ())
}
/// Registers an L2CAP connection oriented channel server. When a client connects to the server,
/// the `server` callback is passed a handle to the established channel. When optional arguments
/// are not specified, the Python module specifies the defaults.
pub fn register_l2cap_channel_server(
&mut self,
psm: u16,
server: impl Fn(Python, LeConnectionOrientedChannel) -> PyResult<()> + Send + 'static,
max_credits: Option<u16>,
mtu: Option<u16>,
mps: Option<u16>,
) -> PyResult<()> {
Python::with_gil(|py| {
let boxed = ClosureCallback::new(move |py, args, _kwargs| {
server(
py,
LeConnectionOrientedChannel::from(args.get_item(0)?.into()),
)
});
let kwargs = PyDict::new(py);
kwargs.set_item("psm", psm)?;
kwargs.set_item("server", boxed.into_py(py))?;
kwargs.set_opt_item("max_credits", max_credits)?;
kwargs.set_opt_item("mtu", mtu)?;
kwargs.set_opt_item("mps", mps)?;
self.0.call_method(
py,
intern!(py, "register_l2cap_channel_server"),
(),
Some(kwargs),
)
})?;
Ok(())
}
}
/// A connection to a remote device.
pub struct Connection(PyObject);
impl Connection {
/// Open an L2CAP channel using this connection. When optional arguments are not specified, the
/// Python module specifies the defaults.
pub async fn open_l2cap_channel(
&mut self,
psm: u16,
max_credits: Option<u16>,
mtu: Option<u16>,
mps: Option<u16>,
) -> PyResult<LeConnectionOrientedChannel> {
Python::with_gil(|py| {
let kwargs = PyDict::new(py);
kwargs.set_item("psm", psm)?;
kwargs.set_opt_item("max_credits", max_credits)?;
kwargs.set_opt_item("mtu", mtu)?;
kwargs.set_opt_item("mps", mps)?;
self.0
.call_method(py, intern!(py, "open_l2cap_channel"), (), Some(kwargs))
.and_then(|coroutine| pyo3_asyncio::tokio::into_future(coroutine.as_ref(py)))
})?
.await
.map(LeConnectionOrientedChannel::from)
}
/// Disconnect from device with provided reason. When optional arguments are not specified, the
/// Python module specifies the defaults.
pub async fn disconnect(&mut self, reason: Option<HciErrorCode>) -> PyResult<()> {
Python::with_gil(|py| {
let kwargs = PyDict::new(py);
kwargs.set_opt_item("reason", reason)?;
self.0
.call_method(py, intern!(py, "disconnect"), (), Some(kwargs))
.and_then(|coroutine| pyo3_asyncio::tokio::into_future(coroutine.as_ref(py)))
})?
.await
.map(|_| ())
}
/// Register a callback to be called on disconnection.
pub fn on_disconnection(
&mut self,
callback: impl Fn(Python, HciErrorCode) -> PyResult<()> + Send + 'static,
) -> PyResult<()> {
let boxed = ClosureCallback::new(move |py, args, _kwargs| {
callback(py, args.get_item(0)?.extract()?)
});
Python::with_gil(|py| {
self.0
.call_method1(py, intern!(py, "add_listener"), ("disconnection", boxed))
})
.map(|_| ())
}
/// Returns some information about the connection as a [String].
pub fn debug_string(&self) -> PyResult<String> {
Python::with_gil(|py| {
let str_obj = self.0.call_method0(py, intern!(py, "__str__"))?;
str_obj.gil_ref(py).extract()
})
}
}
/// The other end of a connection
pub struct Peer(PyObject);
+1 -34
View File
@@ -15,40 +15,7 @@
//! HCI
use itertools::Itertools as _;
use pyo3::{
exceptions::PyException, intern, types::PyModule, FromPyObject, PyAny, PyErr, PyObject,
PyResult, Python, ToPyObject,
};
/// HCI error code.
pub struct HciErrorCode(u8);
impl<'source> FromPyObject<'source> for HciErrorCode {
fn extract(ob: &'source PyAny) -> PyResult<Self> {
Ok(HciErrorCode(ob.extract()?))
}
}
impl ToPyObject for HciErrorCode {
fn to_object(&self, py: Python<'_>) -> PyObject {
self.0.to_object(py)
}
}
/// Provides helpers for interacting with HCI
pub struct HciConstant;
impl HciConstant {
/// Human-readable error name
pub fn error_name(status: HciErrorCode) -> PyResult<String> {
Python::with_gil(|py| {
PyModule::import(py, intern!(py, "bumble.hci"))?
.getattr(intern!(py, "HCI_Constant"))?
.call_method1(intern!(py, "error_name"), (status.0,))?
.extract()
})
}
}
use pyo3::{exceptions::PyException, intern, types::PyModule, PyErr, PyObject, PyResult, Python};
/// A Bluetooth address
pub struct Address(pub(crate) PyObject);
-92
View File
@@ -1,92 +0,0 @@
// Copyright 2023 Google LLC
//
// 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.
//! L2CAP
use crate::wrapper::{ClosureCallback, PyObjectExt};
use pyo3::{intern, PyObject, PyResult, Python};
/// L2CAP connection-oriented channel
pub struct LeConnectionOrientedChannel(PyObject);
impl LeConnectionOrientedChannel {
/// Create a LeConnectionOrientedChannel that wraps the provided obj.
pub(crate) fn from(obj: PyObject) -> Self {
Self(obj)
}
/// Queues data to be automatically sent across this channel.
pub fn write(&mut self, data: &[u8]) -> PyResult<()> {
Python::with_gil(|py| self.0.call_method1(py, intern!(py, "write"), (data,))).map(|_| ())
}
/// Wait for queued data to be sent on this channel.
pub async fn drain(&mut self) -> PyResult<()> {
Python::with_gil(|py| {
self.0
.call_method0(py, intern!(py, "drain"))
.and_then(|coroutine| pyo3_asyncio::tokio::into_future(coroutine.as_ref(py)))
})?
.await
.map(|_| ())
}
/// Register a callback to be called when the channel is closed.
pub fn on_close(
&mut self,
callback: impl Fn(Python) -> PyResult<()> + Send + 'static,
) -> PyResult<()> {
let boxed = ClosureCallback::new(move |py, _args, _kwargs| callback(py));
Python::with_gil(|py| {
self.0
.call_method1(py, intern!(py, "add_listener"), ("close", boxed))
})
.map(|_| ())
}
/// Register a callback to be called when the channel receives data.
pub fn set_sink(
&mut self,
callback: impl Fn(Python, &[u8]) -> PyResult<()> + Send + 'static,
) -> PyResult<()> {
let boxed = ClosureCallback::new(move |py, args, _kwargs| {
callback(py, args.get_item(0)?.extract()?)
});
Python::with_gil(|py| self.0.setattr(py, intern!(py, "sink"), boxed)).map(|_| ())
}
/// Disconnect the l2cap channel.
/// Must be called from a thread with a Python event loop, which should be true on
/// `tokio::main` and `async_std::main`.
///
/// For more info, see https://awestlake87.github.io/pyo3-asyncio/master/doc/pyo3_asyncio/#event-loop-references-and-contextvars.
pub async fn disconnect(&mut self) -> PyResult<()> {
Python::with_gil(|py| {
self.0
.call_method0(py, intern!(py, "disconnect"))
.and_then(|coroutine| pyo3_asyncio::tokio::into_future(coroutine.as_ref(py)))
})?
.await
.map(|_| ())
}
/// Returns some information about the channel as a [String].
pub fn debug_string(&self) -> PyResult<String> {
Python::with_gil(|py| {
let str_obj = self.0.call_method0(py, intern!(py, "__str__"))?;
str_obj.gil_ref(py).extract()
})
}
}
+1 -16
View File
@@ -31,11 +31,11 @@ pub use pyo3_asyncio;
pub mod assigned_numbers;
pub mod core;
pub mod device;
pub mod drivers;
pub mod gatt_client;
pub mod hci;
pub mod host;
pub mod l2cap;
pub mod logging;
pub mod profile;
pub mod transport;
@@ -71,21 +71,6 @@ impl PyObjectExt for PyObject {
}
}
/// Convenience extensions to [PyDict]
pub trait PyDictExt {
/// Set item in dict only if value is Some, otherwise do nothing.
fn set_opt_item<K: ToPyObject, V: ToPyObject>(&self, key: K, value: Option<V>) -> PyResult<()>;
}
impl PyDictExt for PyDict {
fn set_opt_item<K: ToPyObject, V: ToPyObject>(&self, key: K, value: Option<V>) -> PyResult<()> {
if let Some(value) = value {
self.set_item(key, value)?
}
Ok(())
}
}
/// Wrapper to make Rust closures ([Fn] implementations) callable from Python.
///
/// The Python callable form returns a Python `None`.