mirror of
https://github.com/google/bumble.git
synced 2026-04-17 00:35:31 +00:00
Compare commits
5 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9303f4fc5b | ||
|
|
9732eb8836 | ||
|
|
5ae668bc70 | ||
|
|
fd4d1bcca3 | ||
|
|
0e2fc80509 |
17
.github/workflows/python-build-test.yml
vendored
17
.github/workflows/python-build-test.yml
vendored
@@ -45,7 +45,8 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
matrix:
|
||||
python-version: [ "3.8", "3.9", "3.10" ]
|
||||
python-version: [ "3.8", "3.9", "3.10", "3.11" ]
|
||||
rust-version: [ "1.70.0", "stable" ]
|
||||
fail-fast: false
|
||||
steps:
|
||||
- name: Check out from Git
|
||||
@@ -62,9 +63,15 @@ jobs:
|
||||
uses: actions-rust-lang/setup-rust-toolchain@v1
|
||||
with:
|
||||
components: clippy,rustfmt
|
||||
- name: Rust Lints
|
||||
run: cd rust && cargo fmt --check && cargo clippy --all-targets -- --deny warnings
|
||||
toolchain: ${{ matrix.rust-version }}
|
||||
- name: Rust Build
|
||||
run: cd rust && cargo build --all-targets
|
||||
run: cd rust && cargo build --all-targets && cargo build --all-features --all-targets
|
||||
# Lints after build so what clippy needs is already built
|
||||
- name: Rust Lints
|
||||
run: cd rust && cargo fmt --check && cargo clippy --all-targets -- --deny warnings && cargo clippy --all-features --all-targets -- --deny warnings
|
||||
- name: Rust Tests
|
||||
run: cd rust && cargo test
|
||||
run: cd rust && cargo test
|
||||
# At some point, hook up publishing the binary. For now, just make sure it builds.
|
||||
# Once we're ready to publish binaries, this should be built with `--release`.
|
||||
- name: Build Bumble CLI
|
||||
run: cd rust && cargo build --features bumble-tools --bin bumble
|
||||
@@ -105,7 +105,7 @@ class ServerBridge:
|
||||
asyncio.create_task(self.pipe.l2cap_channel.disconnect())
|
||||
|
||||
def data_received(self, data):
|
||||
print(f'<<< Received on TCP: {len(data)}')
|
||||
print(color(f'<<< [TCP DATA]: {len(data)} bytes', 'blue'))
|
||||
self.pipe.l2cap_channel.write(data)
|
||||
|
||||
try:
|
||||
@@ -123,6 +123,7 @@ 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()
|
||||
|
||||
@@ -21,6 +21,9 @@ like loading firmware after a cold start.
|
||||
# -----------------------------------------------------------------------------
|
||||
import abc
|
||||
import logging
|
||||
import pathlib
|
||||
import platform
|
||||
import platformdirs
|
||||
from . import rtk
|
||||
|
||||
|
||||
@@ -66,3 +69,22 @@ async def get_driver_for_host(host):
|
||||
return driver
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def project_data_dir() -> pathlib.Path:
|
||||
"""
|
||||
Returns:
|
||||
A path to an OS-specific directory for bumble data. The directory is created if
|
||||
it doesn't exist.
|
||||
"""
|
||||
if platform.system() == 'Darwin':
|
||||
# platformdirs doesn't handle macOS right: it doesn't assemble a bundle id
|
||||
# out of author & project
|
||||
return platformdirs.user_data_path(
|
||||
appname='com.google.bumble', ensure_exists=True
|
||||
)
|
||||
else:
|
||||
# windows and linux don't use the com qualifier
|
||||
return platformdirs.user_data_path(
|
||||
appname='bumble', appauthor='google', ensure_exists=True
|
||||
)
|
||||
|
||||
@@ -446,6 +446,11 @@ class Driver:
|
||||
# When the environment variable is set, don't look elsewhere
|
||||
return None
|
||||
|
||||
# Then, look where the firmware download tool writes by default
|
||||
if (path := rtk_firmware_dir() / file_name).is_file():
|
||||
logger.debug(f"{file_name} found in project data dir")
|
||||
return path
|
||||
|
||||
# Then, look in the package's driver directory
|
||||
if (path := pathlib.Path(__file__).parent / "rtk_fw" / file_name).is_file():
|
||||
logger.debug(f"{file_name} found in package dir")
|
||||
@@ -646,3 +651,16 @@ class Driver:
|
||||
await self.download_firmware()
|
||||
await self.host.send_command(HCI_Reset_Command(), check_result=True)
|
||||
logger.info(f"loaded FW image {self.driver_info.fw_name}")
|
||||
|
||||
|
||||
def rtk_firmware_dir() -> pathlib.Path:
|
||||
"""
|
||||
Returns:
|
||||
A path to a subdir of the project data dir for Realtek firmware.
|
||||
The directory is created if it doesn't exist.
|
||||
"""
|
||||
from bumble.drivers import project_data_dir
|
||||
|
||||
p = project_data_dir() / "firmware" / "realtek"
|
||||
p.mkdir(parents=True, exist_ok=True)
|
||||
return p
|
||||
|
||||
833
rust/Cargo.lock
generated
833
rust/Cargo.lock
generated
File diff suppressed because it is too large
Load Diff
@@ -10,12 +10,12 @@ documentation = "https://docs.rs/crate/bumble"
|
||||
authors = ["Marshall Pierce <marshallpierce@google.com>"]
|
||||
keywords = ["bluetooth", "ble"]
|
||||
categories = ["api-bindings", "network-programming"]
|
||||
rust-version = "1.69.0"
|
||||
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" }
|
||||
tokio = { version = "1.28.2", features = ["macros", "signal"] }
|
||||
nom = "7.1.3"
|
||||
strum = "0.25.0"
|
||||
strum_macros = "0.25.0"
|
||||
@@ -23,7 +23,17 @@ hex = "0.4.3"
|
||||
itertools = "0.11.0"
|
||||
lazy_static = "1.4.0"
|
||||
thiserror = "1.0.41"
|
||||
|
||||
# CLI
|
||||
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 }
|
||||
|
||||
[dev-dependencies]
|
||||
tokio = { version = "1.28.2", features = ["full"] }
|
||||
@@ -32,17 +42,25 @@ nix = "0.26.2"
|
||||
anyhow = "1.0.71"
|
||||
pyo3 = { version = "0.18.3", features = ["macros", "anyhow"] }
|
||||
pyo3-asyncio = { version = "0.18.0", features = ["tokio-runtime", "attributes", "testing"] }
|
||||
rusb = "0.9.2"
|
||||
rand = "0.8.5"
|
||||
clap = { version = "4.3.3", features = ["derive"] }
|
||||
owo-colors = "3.5.0"
|
||||
log = "0.4.19"
|
||||
env_logger = "0.10.0"
|
||||
rusb = "0.9.2"
|
||||
rand = "0.8.5"
|
||||
|
||||
[package.metadata.docs.rs]
|
||||
rustdoc-args = ["--generate-link-to-definition"]
|
||||
|
||||
[[bin]]
|
||||
name = "gen-assigned-numbers"
|
||||
path = "tools/gen_assigned_numbers.rs"
|
||||
required-features = ["bumble-dev-tools"]
|
||||
required-features = ["bumble-codegen"]
|
||||
|
||||
[[bin]]
|
||||
name = "bumble"
|
||||
path = "src/main.rs"
|
||||
required-features = ["bumble-tools"]
|
||||
|
||||
# test entry point that uses pyo3_asyncio's test harness
|
||||
[[test]]
|
||||
@@ -53,4 +71,7 @@ harness = false
|
||||
[features]
|
||||
anyhow = ["pyo3/anyhow"]
|
||||
pyo3-asyncio-attributes = ["pyo3-asyncio/attributes"]
|
||||
bumble-dev-tools = ["dep:anyhow"]
|
||||
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 = []
|
||||
|
||||
@@ -5,7 +5,8 @@ Rust wrappers around the [Bumble](https://github.com/google/bumble) Python API.
|
||||
Method calls are mapped to the equivalent Python, and return types adapted where
|
||||
relevant.
|
||||
|
||||
See the `examples` directory for usage.
|
||||
See the CLI in `src/main.rs` or the `examples` directory for how to use the
|
||||
Bumble API.
|
||||
|
||||
# Usage
|
||||
|
||||
@@ -27,6 +28,15 @@ PYTHONPATH=..:~/.virtualenvs/bumble/lib/python3.10/site-packages/ \
|
||||
Run the corresponding `battery_server` Python example, and launch an emulator in
|
||||
Android Studio (currently, Canary is required) to run netsim.
|
||||
|
||||
# CLI
|
||||
|
||||
Explore the available subcommands:
|
||||
|
||||
```
|
||||
PYTHONPATH=..:[virtualenv site-packages] \
|
||||
cargo run --features bumble-tools --bin bumble -- --help
|
||||
```
|
||||
|
||||
# Development
|
||||
|
||||
Run the tests:
|
||||
@@ -43,7 +53,7 @@ cargo clippy --all-targets
|
||||
|
||||
## Code gen
|
||||
|
||||
To have the fastest startup while keeping the build simple, code gen for
|
||||
To have the fastest startup while keeping the build simple, code gen for
|
||||
assigned numbers is done with the `gen_assigned_numbers` tool. It should
|
||||
be re-run whenever the Python assigned numbers are changed. To ensure that the
|
||||
generated code is kept up to date, the Rust data is compared to the Python
|
||||
@@ -52,5 +62,5 @@ in tests at `pytests/assigned_numbers.rs`.
|
||||
To regenerate the assigned number tables based on the Python codebase:
|
||||
|
||||
```
|
||||
PYTHONPATH=.. cargo run --bin gen-assigned-numbers --features bumble-dev-tools
|
||||
PYTHONPATH=.. cargo run --bin gen-assigned-numbers --features bumble-codegen
|
||||
```
|
||||
@@ -12,7 +12,7 @@
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
use bumble::wrapper::transport::Transport;
|
||||
use bumble::wrapper::{drivers::rtk::DriverInfo, transport::Transport};
|
||||
use nix::sys::stat::Mode;
|
||||
use pyo3::PyResult;
|
||||
|
||||
@@ -29,3 +29,9 @@ async fn fifo_transport_can_open() -> PyResult<()> {
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[pyo3_asyncio::tokio::test]
|
||||
async fn realtek_driver_info_all_drivers() -> PyResult<()> {
|
||||
assert_eq!(12, DriverInfo::all_drivers()?.len());
|
||||
Ok(())
|
||||
}
|
||||
|
||||
4
rust/resources/test/firmware/realtek/README.md
Normal file
4
rust/resources/test/firmware/realtek/README.md
Normal file
@@ -0,0 +1,4 @@
|
||||
This dir contains samples firmware images in the format used for Realtek chips,
|
||||
but with repetitions of the length of the section as a little-endian 32-bit int
|
||||
for the patch data instead of actual firmware, since we only need the structure
|
||||
to test parsing.
|
||||
BIN
rust/resources/test/firmware/realtek/rtl8723b_fw_structure.bin
Normal file
BIN
rust/resources/test/firmware/realtek/rtl8723b_fw_structure.bin
Normal file
Binary file not shown.
BIN
rust/resources/test/firmware/realtek/rtl8761bu_fw_structure.bin
Normal file
BIN
rust/resources/test/firmware/realtek/rtl8761bu_fw_structure.bin
Normal file
Binary file not shown.
15
rust/src/cli/firmware/mod.rs
Normal file
15
rust/src/cli/firmware/mod.rs
Normal file
@@ -0,0 +1,15 @@
|
||||
// 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.
|
||||
|
||||
pub(crate) mod rtk;
|
||||
265
rust/src/cli/firmware/rtk.rs
Normal file
265
rust/src/cli/firmware/rtk.rs
Normal file
@@ -0,0 +1,265 @@
|
||||
// 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.
|
||||
|
||||
//! Realtek firmware tools
|
||||
|
||||
use crate::{Download, Source};
|
||||
use anyhow::anyhow;
|
||||
use bumble::wrapper::{
|
||||
drivers::rtk::{Driver, DriverInfo, Firmware},
|
||||
host::{DriverFactory, Host},
|
||||
transport::Transport,
|
||||
};
|
||||
use owo_colors::{colors::css, OwoColorize};
|
||||
use pyo3::PyResult;
|
||||
use std::{fs, path};
|
||||
|
||||
pub(crate) async fn download(dl: Download) -> PyResult<()> {
|
||||
let data_dir = dl
|
||||
.output_dir
|
||||
.or_else(|| {
|
||||
directories::ProjectDirs::from("com", "google", "bumble")
|
||||
.map(|pd| pd.data_local_dir().join("firmware").join("realtek"))
|
||||
})
|
||||
.unwrap_or_else(|| {
|
||||
eprintln!("Could not determine standard data directory");
|
||||
path::PathBuf::from(".")
|
||||
});
|
||||
fs::create_dir_all(&data_dir)?;
|
||||
|
||||
let (base_url, uses_bin_suffix) = match dl.source {
|
||||
Source::LinuxKernel => ("https://git.kernel.org/pub/scm/linux/kernel/git/firmware/linux-firmware.git/plain/rtl_bt", true),
|
||||
Source::RealtekOpensource => ("https://github.com/Realtek-OpenSource/android_hardware_realtek/raw/rtk1395/bt/rtkbt/Firmware/BT", false),
|
||||
Source::LinuxFromScratch => ("https://anduin.linuxfromscratch.org/sources/linux-firmware/rtl_bt", true),
|
||||
};
|
||||
|
||||
println!("Downloading");
|
||||
println!("{} {}", "FROM:".green(), base_url);
|
||||
println!("{} {}", "TO:".green(), data_dir.to_string_lossy());
|
||||
|
||||
let url_for_file = |file_name: &str| {
|
||||
let url_suffix = if uses_bin_suffix {
|
||||
file_name
|
||||
} else {
|
||||
file_name.trim_end_matches(".bin")
|
||||
};
|
||||
|
||||
let mut url = base_url.to_string();
|
||||
url.push('/');
|
||||
url.push_str(url_suffix);
|
||||
url
|
||||
};
|
||||
|
||||
let to_download = if let Some(single) = dl.single {
|
||||
vec![(
|
||||
format!("{single}_fw.bin"),
|
||||
Some(format!("{single}_config.bin")),
|
||||
false,
|
||||
)]
|
||||
} else {
|
||||
DriverInfo::all_drivers()?
|
||||
.iter()
|
||||
.map(|di| Ok((di.firmware_name()?, di.config_name()?, di.config_needed()?)))
|
||||
.collect::<PyResult<Vec<_>>>()?
|
||||
};
|
||||
|
||||
let client = SimpleClient::new();
|
||||
|
||||
for (fw_filename, config_filename, config_needed) in to_download {
|
||||
println!("{}", "---".yellow());
|
||||
let fw_path = data_dir.join(&fw_filename);
|
||||
let config_path = config_filename.as_ref().map(|f| data_dir.join(f));
|
||||
|
||||
if fw_path.exists() && !dl.overwrite {
|
||||
println!(
|
||||
"{}",
|
||||
format!("{} already exists, skipping", fw_path.to_string_lossy())
|
||||
.fg::<css::Orange>()
|
||||
);
|
||||
continue;
|
||||
}
|
||||
if let Some(cp) = config_path.as_ref() {
|
||||
if cp.exists() && !dl.overwrite {
|
||||
println!(
|
||||
"{}",
|
||||
format!("{} already exists, skipping", cp.to_string_lossy())
|
||||
.fg::<css::Orange>()
|
||||
);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
let fw_contents = match client.get(&url_for_file(&fw_filename)).await {
|
||||
Ok(data) => {
|
||||
println!("Downloaded {}: {} bytes", fw_filename, data.len());
|
||||
data
|
||||
}
|
||||
Err(e) => {
|
||||
eprintln!(
|
||||
"{} {} {:?}",
|
||||
"Failed to download".red(),
|
||||
fw_filename.red(),
|
||||
e
|
||||
);
|
||||
continue;
|
||||
}
|
||||
};
|
||||
|
||||
let config_contents = if let Some(cn) = &config_filename {
|
||||
match client.get(&url_for_file(cn)).await {
|
||||
Ok(data) => {
|
||||
println!("Downloaded {}: {} bytes", cn, data.len());
|
||||
Some(data)
|
||||
}
|
||||
Err(e) => {
|
||||
if config_needed {
|
||||
eprintln!("{} {} {:?}", "Failed to download".red(), cn.red(), e);
|
||||
continue;
|
||||
} else {
|
||||
eprintln!(
|
||||
"{}",
|
||||
format!("No config available as {cn}").fg::<css::Orange>()
|
||||
);
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
fs::write(&fw_path, &fw_contents)?;
|
||||
if !dl.no_parse && config_filename.is_some() {
|
||||
println!("{} {}", "Parsing:".cyan(), &fw_filename);
|
||||
match Firmware::parse(&fw_contents).map_err(|e| anyhow!("Parse error: {:?}", e)) {
|
||||
Ok(fw) => dump_firmware_desc(&fw),
|
||||
Err(e) => {
|
||||
eprintln!(
|
||||
"{} {:?}",
|
||||
"Could not parse firmware:".fg::<css::Orange>(),
|
||||
e
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
if let Some((cp, cd)) = config_path
|
||||
.as_ref()
|
||||
.and_then(|p| config_contents.map(|c| (p, c)))
|
||||
{
|
||||
fs::write(cp, &cd)?;
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(crate) fn parse(firmware_path: &path::Path) -> PyResult<()> {
|
||||
let contents = fs::read(firmware_path)?;
|
||||
let fw = Firmware::parse(&contents)
|
||||
// squish the error into a string to avoid the error type requiring that the input be
|
||||
// 'static
|
||||
.map_err(|e| anyhow!("Parse error: {:?}", e))?;
|
||||
|
||||
dump_firmware_desc(&fw);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(crate) async fn info(transport: &str, force: bool) -> PyResult<()> {
|
||||
let transport = Transport::open(transport).await?;
|
||||
|
||||
let mut host = Host::new(transport.source()?, transport.sink()?)?;
|
||||
host.reset(DriverFactory::None).await?;
|
||||
|
||||
if !force && !Driver::check(&host).await? {
|
||||
println!("USB device not supported by this RTK driver");
|
||||
} else if let Some(driver_info) = Driver::driver_info_for_host(&host).await? {
|
||||
println!("Driver:");
|
||||
println!(" {:10} {:04X}", "ROM:", driver_info.rom()?);
|
||||
println!(" {:10} {}", "Firmware:", driver_info.firmware_name()?);
|
||||
println!(
|
||||
" {:10} {}",
|
||||
"Config:",
|
||||
driver_info.config_name()?.unwrap_or_default()
|
||||
);
|
||||
} else {
|
||||
println!("Firmware already loaded or no supported driver for this device.")
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(crate) async fn load(transport: &str, force: bool) -> PyResult<()> {
|
||||
let transport = Transport::open(transport).await?;
|
||||
|
||||
let mut host = Host::new(transport.source()?, transport.sink()?)?;
|
||||
host.reset(DriverFactory::None).await?;
|
||||
|
||||
match Driver::for_host(&host, force).await? {
|
||||
None => {
|
||||
eprintln!("Firmware already loaded or no supported driver for this device.");
|
||||
}
|
||||
Some(mut d) => d.download_firmware().await?,
|
||||
};
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(crate) async fn drop(transport: &str) -> PyResult<()> {
|
||||
let transport = Transport::open(transport).await?;
|
||||
|
||||
let mut host = Host::new(transport.source()?, transport.sink()?)?;
|
||||
host.reset(DriverFactory::None).await?;
|
||||
|
||||
Driver::drop_firmware(&mut host).await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn dump_firmware_desc(fw: &Firmware) {
|
||||
println!(
|
||||
"Firmware: version=0x{:08X} project_id=0x{:04X}",
|
||||
fw.version(),
|
||||
fw.project_id()
|
||||
);
|
||||
for p in fw.patches() {
|
||||
println!(
|
||||
" Patch: chip_id=0x{:04X}, {} bytes, SVN Version={:08X}",
|
||||
p.chip_id(),
|
||||
p.contents().len(),
|
||||
p.svn_version()
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
struct SimpleClient {
|
||||
client: reqwest::Client,
|
||||
}
|
||||
|
||||
impl SimpleClient {
|
||||
fn new() -> Self {
|
||||
Self {
|
||||
client: reqwest::Client::new(),
|
||||
}
|
||||
}
|
||||
|
||||
async fn get(&self, url: &str) -> anyhow::Result<Vec<u8>> {
|
||||
let resp = self.client.get(url).send().await?;
|
||||
if !resp.status().is_success() {
|
||||
return Err(anyhow!("Bad status: {}", resp.status()));
|
||||
}
|
||||
let bytes = resp.bytes().await?;
|
||||
Ok(bytes.as_ref().to_vec())
|
||||
}
|
||||
}
|
||||
191
rust/src/cli/l2cap/client_bridge.rs
Normal file
191
rust/src/cli/l2cap/client_bridge.rs
Normal file
@@ -0,0 +1,191 @@
|
||||
// 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
rust/src/cli/l2cap/mod.rs
Normal file
190
rust/src/cli/l2cap/mod.rs
Normal file
@@ -0,0 +1,190 @@
|
||||
// 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
rust/src/cli/l2cap/server_bridge.rs
Normal file
205
rust/src/cli/l2cap/server_bridge.rs
Normal file
@@ -0,0 +1,205 @@
|
||||
// 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(())
|
||||
}
|
||||
19
rust/src/cli/mod.rs
Normal file
19
rust/src/cli/mod.rs
Normal file
@@ -0,0 +1,19 @@
|
||||
// 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.
|
||||
|
||||
pub(crate) mod firmware;
|
||||
|
||||
pub(crate) mod usb;
|
||||
|
||||
pub(crate) mod l2cap;
|
||||
@@ -23,7 +23,6 @@
|
||||
//! whether it is a Bluetooth device that uses a non-standard Class, or some other
|
||||
//! type of device (there's no way to tell).
|
||||
|
||||
use clap::Parser as _;
|
||||
use itertools::Itertools as _;
|
||||
use owo_colors::{OwoColorize, Style};
|
||||
use rusb::{Device, DeviceDescriptor, Direction, TransferType, UsbContext};
|
||||
@@ -31,15 +30,12 @@ use std::{
|
||||
collections::{HashMap, HashSet},
|
||||
time::Duration,
|
||||
};
|
||||
|
||||
const USB_DEVICE_CLASS_DEVICE: u8 = 0x00;
|
||||
const USB_DEVICE_CLASS_WIRELESS_CONTROLLER: u8 = 0xE0;
|
||||
const USB_DEVICE_SUBCLASS_RF_CONTROLLER: u8 = 0x01;
|
||||
const USB_DEVICE_PROTOCOL_BLUETOOTH_PRIMARY_CONTROLLER: u8 = 0x01;
|
||||
|
||||
fn main() -> anyhow::Result<()> {
|
||||
let cli = Cli::parse();
|
||||
|
||||
pub(crate) fn probe(verbose: bool) -> anyhow::Result<()> {
|
||||
let mut bt_dev_count = 0;
|
||||
let mut device_serials_by_id: HashMap<(u16, u16), HashSet<String>> = HashMap::new();
|
||||
for device in rusb::devices()?.iter() {
|
||||
@@ -159,7 +155,7 @@ fn main() -> anyhow::Result<()> {
|
||||
println!("{:26}{}", " Product:".green(), p);
|
||||
}
|
||||
|
||||
if cli.verbose {
|
||||
if verbose {
|
||||
print_device_details(&device, &device_desc)?;
|
||||
}
|
||||
|
||||
@@ -332,11 +328,3 @@ impl From<&DeviceDescriptor> for ClassInfo {
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(clap::Parser)]
|
||||
#[command(author, version, about, long_about = None)]
|
||||
struct Cli {
|
||||
/// Show additional info for each USB device
|
||||
#[arg(long, default_value_t = false)]
|
||||
verbose: bool,
|
||||
}
|
||||
17
rust/src/internal/drivers/mod.rs
Normal file
17
rust/src/internal/drivers/mod.rs
Normal file
@@ -0,0 +1,17 @@
|
||||
// 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.
|
||||
|
||||
//! Device drivers
|
||||
|
||||
pub(crate) mod rtk;
|
||||
253
rust/src/internal/drivers/rtk.rs
Normal file
253
rust/src/internal/drivers/rtk.rs
Normal file
@@ -0,0 +1,253 @@
|
||||
// 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.
|
||||
|
||||
//! Drivers for Realtek controllers
|
||||
|
||||
use nom::{bytes, combinator, error, multi, number, sequence};
|
||||
|
||||
/// Realtek firmware file contents
|
||||
pub struct Firmware {
|
||||
version: u32,
|
||||
project_id: u8,
|
||||
patches: Vec<Patch>,
|
||||
}
|
||||
|
||||
impl Firmware {
|
||||
/// Parse a `*_fw.bin` file
|
||||
pub fn parse(input: &[u8]) -> Result<Self, nom::Err<error::Error<&[u8]>>> {
|
||||
let extension_sig = [0x51, 0x04, 0xFD, 0x77];
|
||||
|
||||
let (_rem, (_tag, fw_version, patch_count, payload)) =
|
||||
combinator::all_consuming(combinator::map_parser(
|
||||
// ignore the sig suffix
|
||||
sequence::terminated(
|
||||
bytes::complete::take(
|
||||
// underflow will show up as parse failure
|
||||
input.len().saturating_sub(extension_sig.len()),
|
||||
),
|
||||
bytes::complete::tag(extension_sig.as_slice()),
|
||||
),
|
||||
sequence::tuple((
|
||||
bytes::complete::tag(b"Realtech"),
|
||||
// version
|
||||
number::complete::le_u32,
|
||||
// patch count
|
||||
combinator::map(number::complete::le_u16, |c| c as usize),
|
||||
// everything else except suffix
|
||||
combinator::rest,
|
||||
)),
|
||||
))(input)?;
|
||||
|
||||
// ignore remaining input, since patch offsets are relative to the complete input
|
||||
let (_rem, (chip_ids, patch_lengths, patch_offsets)) = sequence::tuple((
|
||||
// chip id
|
||||
multi::many_m_n(patch_count, patch_count, number::complete::le_u16),
|
||||
// patch length
|
||||
multi::many_m_n(patch_count, patch_count, number::complete::le_u16),
|
||||
// patch offset
|
||||
multi::many_m_n(patch_count, patch_count, number::complete::le_u32),
|
||||
))(payload)?;
|
||||
|
||||
let patches = chip_ids
|
||||
.into_iter()
|
||||
.zip(patch_lengths.into_iter())
|
||||
.zip(patch_offsets.into_iter())
|
||||
.map(|((chip_id, patch_length), patch_offset)| {
|
||||
combinator::map(
|
||||
sequence::preceded(
|
||||
bytes::complete::take(patch_offset),
|
||||
// ignore trailing 4-byte suffix
|
||||
sequence::terminated(
|
||||
// patch including svn version, but not suffix
|
||||
combinator::consumed(sequence::preceded(
|
||||
// patch before svn version or version suffix
|
||||
// prefix length underflow will show up as parse failure
|
||||
bytes::complete::take(patch_length.saturating_sub(8)),
|
||||
// svn version
|
||||
number::complete::le_u32,
|
||||
)),
|
||||
// dummy suffix, overwritten with firmware version
|
||||
bytes::complete::take(4_usize),
|
||||
),
|
||||
),
|
||||
|(patch_contents_before_version, svn_version): (&[u8], u32)| {
|
||||
let mut contents = patch_contents_before_version.to_vec();
|
||||
// replace what would have been the trailing dummy suffix with fw version
|
||||
contents.extend_from_slice(&fw_version.to_le_bytes());
|
||||
|
||||
Patch {
|
||||
contents,
|
||||
svn_version,
|
||||
chip_id,
|
||||
}
|
||||
},
|
||||
)(input)
|
||||
.map(|(_rem, output)| output)
|
||||
})
|
||||
.collect::<Result<Vec<_>, _>>()?;
|
||||
|
||||
// look for project id from the end
|
||||
let mut offset = payload.len();
|
||||
let mut project_id: Option<u8> = None;
|
||||
while offset >= 2 {
|
||||
// Won't panic, since offset >= 2
|
||||
let chunk = &payload[offset - 2..offset];
|
||||
let length: usize = chunk[0].into();
|
||||
let opcode = chunk[1];
|
||||
offset -= 2;
|
||||
|
||||
if opcode == 0xFF {
|
||||
break;
|
||||
}
|
||||
if length == 0 {
|
||||
// report what nom likely would have done, if nom was good at parsing backwards
|
||||
return Err(nom::Err::Error(error::Error::new(
|
||||
chunk,
|
||||
error::ErrorKind::Verify,
|
||||
)));
|
||||
}
|
||||
if opcode == 0 && length == 1 {
|
||||
project_id = offset
|
||||
.checked_sub(1)
|
||||
.and_then(|index| payload.get(index))
|
||||
.copied();
|
||||
break;
|
||||
}
|
||||
|
||||
offset -= length;
|
||||
}
|
||||
|
||||
match project_id {
|
||||
Some(project_id) => Ok(Firmware {
|
||||
project_id,
|
||||
version: fw_version,
|
||||
patches,
|
||||
}),
|
||||
None => {
|
||||
// we ran out of file without finding a project id
|
||||
Err(nom::Err::Error(error::Error::new(
|
||||
payload,
|
||||
error::ErrorKind::Eof,
|
||||
)))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Patch version
|
||||
pub fn version(&self) -> u32 {
|
||||
self.version
|
||||
}
|
||||
|
||||
/// Project id
|
||||
pub fn project_id(&self) -> u8 {
|
||||
self.project_id
|
||||
}
|
||||
|
||||
/// Patches
|
||||
pub fn patches(&self) -> &[Patch] {
|
||||
&self.patches
|
||||
}
|
||||
}
|
||||
|
||||
/// Patch in a [Firmware}
|
||||
pub struct Patch {
|
||||
chip_id: u16,
|
||||
contents: Vec<u8>,
|
||||
svn_version: u32,
|
||||
}
|
||||
|
||||
impl Patch {
|
||||
/// Chip id
|
||||
pub fn chip_id(&self) -> u16 {
|
||||
self.chip_id
|
||||
}
|
||||
/// Contents of the patch, including the 4-byte firmware version suffix
|
||||
pub fn contents(&self) -> &[u8] {
|
||||
&self.contents
|
||||
}
|
||||
/// SVN version
|
||||
pub fn svn_version(&self) -> u32 {
|
||||
self.svn_version
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use anyhow::anyhow;
|
||||
use std::{fs, io, path};
|
||||
|
||||
#[test]
|
||||
fn parse_firmware_rtl8723b() -> anyhow::Result<()> {
|
||||
let fw = Firmware::parse(&firmware_contents("rtl8723b_fw_structure.bin")?)
|
||||
.map_err(|e| anyhow!("{:?}", e))?;
|
||||
|
||||
let fw_version = 0x0E2F9F73;
|
||||
assert_eq!(fw_version, fw.version());
|
||||
assert_eq!(0x0001, fw.project_id());
|
||||
assert_eq!(
|
||||
vec![(0x0001, 0x00002BBF, 22368,), (0x0002, 0x00002BBF, 22496,),],
|
||||
patch_summaries(fw, fw_version)
|
||||
);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_firmware_rtl8761bu() -> anyhow::Result<()> {
|
||||
let fw = Firmware::parse(&firmware_contents("rtl8761bu_fw_structure.bin")?)
|
||||
.map_err(|e| anyhow!("{:?}", e))?;
|
||||
|
||||
let fw_version = 0xDFC6D922;
|
||||
assert_eq!(fw_version, fw.version());
|
||||
assert_eq!(0x000E, fw.project_id());
|
||||
assert_eq!(
|
||||
vec![(0x0001, 0x00005060, 14048,), (0x0002, 0xD6D525A4, 30204,),],
|
||||
patch_summaries(fw, fw_version)
|
||||
);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn firmware_contents(filename: &str) -> io::Result<Vec<u8>> {
|
||||
fs::read(
|
||||
path::PathBuf::from(env!("CARGO_MANIFEST_DIR"))
|
||||
.join("resources/test/firmware/realtek")
|
||||
.join(filename),
|
||||
)
|
||||
}
|
||||
|
||||
/// Return a tuple of (chip id, svn version, contents len, contents sha256)
|
||||
fn patch_summaries(fw: Firmware, fw_version: u32) -> Vec<(u16, u32, usize)> {
|
||||
fw.patches()
|
||||
.iter()
|
||||
.map(|p| {
|
||||
let contents = p.contents();
|
||||
let mut dummy_contents = dummy_contents(contents.len());
|
||||
dummy_contents.extend_from_slice(&p.svn_version().to_le_bytes());
|
||||
dummy_contents.extend_from_slice(&fw_version.to_le_bytes());
|
||||
assert_eq!(&dummy_contents, contents);
|
||||
(p.chip_id(), p.svn_version(), contents.len())
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
}
|
||||
|
||||
fn dummy_contents(len: usize) -> Vec<u8> {
|
||||
let mut vec = (len as u32).to_le_bytes().as_slice().repeat(len / 4 + 1);
|
||||
assert!(vec.len() >= len);
|
||||
// leave room for svn version and firmware version
|
||||
vec.truncate(len - 8);
|
||||
vec
|
||||
}
|
||||
}
|
||||
20
rust/src/internal/mod.rs
Normal file
20
rust/src/internal/mod.rs
Normal file
@@ -0,0 +1,20 @@
|
||||
// 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.
|
||||
|
||||
//! It's not clear where to put Rust code that isn't simply a wrapper around Python. Until we have
|
||||
//! a good answer for what to do there, the idea is to put it in this (non-public) module, and
|
||||
//! `pub use` it into the relevant areas of the `wrapper` module so that it's still easy for users
|
||||
//! to discover.
|
||||
|
||||
pub(crate) mod drivers;
|
||||
@@ -29,3 +29,5 @@
|
||||
pub mod wrapper;
|
||||
|
||||
pub mod adv;
|
||||
|
||||
pub(crate) mod internal;
|
||||
|
||||
271
rust/src/main.rs
Normal file
271
rust/src/main.rs
Normal file
@@ -0,0 +1,271 @@
|
||||
// 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.
|
||||
|
||||
//! CLI tools for Bumble
|
||||
|
||||
#![deny(missing_docs, unsafe_code)]
|
||||
|
||||
use bumble::wrapper::logging::{bumble_env_logging_level, py_logging_basic_config};
|
||||
use clap::Parser as _;
|
||||
use pyo3::PyResult;
|
||||
use std::{fmt, path};
|
||||
|
||||
mod cli;
|
||||
|
||||
#[pyo3_asyncio::tokio::main]
|
||||
async fn main() -> PyResult<()> {
|
||||
env_logger::builder()
|
||||
.filter_level(log::LevelFilter::Info)
|
||||
.init();
|
||||
|
||||
py_logging_basic_config(bumble_env_logging_level("INFO"))?;
|
||||
|
||||
let cli: Cli = Cli::parse();
|
||||
|
||||
match cli.subcommand {
|
||||
Subcommand::Firmware { subcommand: fw } => match fw {
|
||||
Firmware::Realtek { subcommand: rtk } => match rtk {
|
||||
Realtek::Download(dl) => {
|
||||
cli::firmware::rtk::download(dl).await?;
|
||||
}
|
||||
Realtek::Drop { transport } => cli::firmware::rtk::drop(&transport).await?,
|
||||
Realtek::Info { transport, force } => {
|
||||
cli::firmware::rtk::info(&transport, force).await?;
|
||||
}
|
||||
Realtek::Load { transport, force } => {
|
||||
cli::firmware::rtk::load(&transport, force).await?
|
||||
}
|
||||
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)?,
|
||||
},
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[derive(clap::Parser)]
|
||||
struct Cli {
|
||||
#[clap(subcommand)]
|
||||
subcommand: Subcommand,
|
||||
}
|
||||
|
||||
#[derive(clap::Subcommand, Debug, Clone)]
|
||||
enum Subcommand {
|
||||
/// Manage device firmware
|
||||
Firmware {
|
||||
#[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)]
|
||||
subcommand: Usb,
|
||||
},
|
||||
}
|
||||
|
||||
#[derive(clap::Subcommand, Debug, Clone)]
|
||||
enum Firmware {
|
||||
/// Manage Realtek chipset firmware
|
||||
Realtek {
|
||||
#[clap(subcommand)]
|
||||
subcommand: Realtek,
|
||||
},
|
||||
}
|
||||
|
||||
#[derive(clap::Subcommand, Debug, Clone)]
|
||||
|
||||
enum Realtek {
|
||||
/// Download Realtek firmware
|
||||
Download(Download),
|
||||
/// Drop firmware from a USB device
|
||||
Drop {
|
||||
/// Bumble transport spec. Must be for a USB device.
|
||||
///
|
||||
/// <https://google.github.io/bumble/transports/index.html>
|
||||
#[arg(long)]
|
||||
transport: String,
|
||||
},
|
||||
/// Show driver info for a USB device
|
||||
Info {
|
||||
/// Bumble transport spec. Must be for a USB device.
|
||||
///
|
||||
/// <https://google.github.io/bumble/transports/index.html>
|
||||
#[arg(long)]
|
||||
transport: String,
|
||||
/// Try to resolve driver info even if USB info is not available, or if the USB
|
||||
/// (vendor,product) tuple is not in the list of known compatible RTK USB dongles.
|
||||
#[arg(long, default_value_t = false)]
|
||||
force: bool,
|
||||
},
|
||||
/// Load firmware onto a USB device
|
||||
Load {
|
||||
/// Bumble transport spec. Must be for a USB device.
|
||||
///
|
||||
/// <https://google.github.io/bumble/transports/index.html>
|
||||
#[arg(long)]
|
||||
transport: String,
|
||||
/// Load firmware even if the USB info doesn't match.
|
||||
#[arg(long, default_value_t = false)]
|
||||
force: bool,
|
||||
},
|
||||
/// Parse a firmware file
|
||||
Parse {
|
||||
/// Firmware file to parse
|
||||
firmware_path: path::PathBuf,
|
||||
},
|
||||
}
|
||||
|
||||
#[derive(clap::Args, Debug, Clone)]
|
||||
struct Download {
|
||||
/// Directory to download to. Defaults to an OS-specific path specific to the Bumble tool.
|
||||
#[arg(long)]
|
||||
output_dir: Option<path::PathBuf>,
|
||||
/// Source to download from
|
||||
#[arg(long, default_value_t = Source::LinuxKernel)]
|
||||
source: Source,
|
||||
/// Only download a single image
|
||||
#[arg(long, value_name = "base name")]
|
||||
single: Option<String>,
|
||||
/// Overwrite existing files
|
||||
#[arg(long, default_value_t = false)]
|
||||
overwrite: bool,
|
||||
/// Don't print the parse results for the downloaded file names
|
||||
#[arg(long)]
|
||||
no_parse: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, clap::ValueEnum)]
|
||||
enum Source {
|
||||
LinuxKernel,
|
||||
RealtekOpensource,
|
||||
LinuxFromScratch,
|
||||
}
|
||||
|
||||
impl fmt::Display for Source {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
match self {
|
||||
Source::LinuxKernel => write!(f, "linux-kernel"),
|
||||
Source::RealtekOpensource => write!(f, "realtek-opensource"),
|
||||
Source::LinuxFromScratch => write!(f, "linux-from-scratch"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[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
|
||||
Probe(Probe),
|
||||
}
|
||||
|
||||
#[derive(clap::Args, Debug, Clone)]
|
||||
struct Probe {
|
||||
/// Show additional info for each USB device
|
||||
#[arg(long, default_value_t = false)]
|
||||
verbose: bool,
|
||||
}
|
||||
@@ -19,13 +19,19 @@ use crate::{
|
||||
wrapper::{
|
||||
core::AdvertisingData,
|
||||
gatt_client::{ProfileServiceProxy, ServiceProxy},
|
||||
hci::Address,
|
||||
hci::{Address, HciErrorCode},
|
||||
host::Host,
|
||||
l2cap::LeConnectionOrientedChannel,
|
||||
transport::{Sink, Source},
|
||||
ClosureCallback,
|
||||
ClosureCallback, PyDictExt, PyObjectExt,
|
||||
},
|
||||
};
|
||||
use pyo3::types::PyDict;
|
||||
use pyo3::{intern, types::PyModule, PyObject, PyResult, Python, ToPyObject};
|
||||
use pyo3::{
|
||||
intern,
|
||||
types::{PyDict, PyModule},
|
||||
IntoPy, PyObject, PyResult, Python, ToPyObject,
|
||||
};
|
||||
use pyo3_asyncio::tokio::into_future;
|
||||
use std::path;
|
||||
|
||||
/// A device that can send/receive HCI frames.
|
||||
@@ -65,7 +71,7 @@ impl Device {
|
||||
Python::with_gil(|py| {
|
||||
self.0
|
||||
.call_method0(py, intern!(py, "power_on"))
|
||||
.and_then(|coroutine| pyo3_asyncio::tokio::into_future(coroutine.as_ref(py)))
|
||||
.and_then(|coroutine| into_future(coroutine.as_ref(py)))
|
||||
})?
|
||||
.await
|
||||
.map(|_| ())
|
||||
@@ -76,12 +82,28 @@ impl Device {
|
||||
Python::with_gil(|py| {
|
||||
self.0
|
||||
.call_method1(py, intern!(py, "connect"), (peer_addr,))
|
||||
.and_then(|coroutine| pyo3_asyncio::tokio::into_future(coroutine.as_ref(py)))
|
||||
.and_then(|coroutine| into_future(coroutine.as_ref(py)))
|
||||
})?
|
||||
.await
|
||||
.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| {
|
||||
@@ -89,7 +111,7 @@ impl Device {
|
||||
kwargs.set_item("filter_duplicates", filter_duplicates)?;
|
||||
self.0
|
||||
.call_method(py, intern!(py, "start_scanning"), (), Some(kwargs))
|
||||
.and_then(|coroutine| pyo3_asyncio::tokio::into_future(coroutine.as_ref(py)))
|
||||
.and_then(|coroutine| into_future(coroutine.as_ref(py)))
|
||||
})?
|
||||
.await
|
||||
.map(|_| ())
|
||||
@@ -123,6 +145,15 @@ impl Device {
|
||||
.map(|_| ())
|
||||
}
|
||||
|
||||
/// Returns the host used by the device, if any
|
||||
pub fn host(&mut self) -> PyResult<Option<Host>> {
|
||||
Python::with_gil(|py| {
|
||||
self.0
|
||||
.getattr(py, intern!(py, "host"))
|
||||
.map(|obj| obj.into_option(Host::from))
|
||||
})
|
||||
}
|
||||
|
||||
/// Start advertising the data set with [Device.set_advertisement].
|
||||
pub async fn start_advertising(&mut self, auto_restart: bool) -> PyResult<()> {
|
||||
Python::with_gil(|py| {
|
||||
@@ -131,7 +162,7 @@ impl Device {
|
||||
|
||||
self.0
|
||||
.call_method(py, intern!(py, "start_advertising"), (), Some(kwargs))
|
||||
.and_then(|coroutine| pyo3_asyncio::tokio::into_future(coroutine.as_ref(py)))
|
||||
.and_then(|coroutine| into_future(coroutine.as_ref(py)))
|
||||
})?
|
||||
.await
|
||||
.map(|_| ())
|
||||
@@ -142,16 +173,114 @@ impl Device {
|
||||
Python::with_gil(|py| {
|
||||
self.0
|
||||
.call_method0(py, intern!(py, "stop_advertising"))
|
||||
.and_then(|coroutine| pyo3_asyncio::tokio::into_future(coroutine.as_ref(py)))
|
||||
.and_then(|coroutine| into_future(coroutine.as_ref(py)))
|
||||
})?
|
||||
.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);
|
||||
|
||||
@@ -173,7 +302,7 @@ impl Peer {
|
||||
Python::with_gil(|py| {
|
||||
self.0
|
||||
.call_method0(py, intern!(py, "discover_services"))
|
||||
.and_then(|coroutine| pyo3_asyncio::tokio::into_future(coroutine.as_ref(py)))
|
||||
.and_then(|coroutine| into_future(coroutine.as_ref(py)))
|
||||
})?
|
||||
.await
|
||||
.and_then(|list| {
|
||||
@@ -207,13 +336,7 @@ impl Peer {
|
||||
let class = module.getattr(P::PROXY_CLASS_NAME)?;
|
||||
self.0
|
||||
.call_method1(py, intern!(py, "create_service_proxy"), (class,))
|
||||
.map(|obj| {
|
||||
if obj.is_none(py) {
|
||||
None
|
||||
} else {
|
||||
Some(P::wrap(obj))
|
||||
}
|
||||
})
|
||||
.map(|obj| obj.into_option(P::wrap))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
17
rust/src/wrapper/drivers/mod.rs
Normal file
17
rust/src/wrapper/drivers/mod.rs
Normal file
@@ -0,0 +1,17 @@
|
||||
// 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.
|
||||
|
||||
//! Device drivers
|
||||
|
||||
pub mod rtk;
|
||||
141
rust/src/wrapper/drivers/rtk.rs
Normal file
141
rust/src/wrapper/drivers/rtk.rs
Normal file
@@ -0,0 +1,141 @@
|
||||
// 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.
|
||||
|
||||
//! Drivers for Realtek controllers
|
||||
|
||||
use crate::wrapper::{host::Host, PyObjectExt};
|
||||
use pyo3::{intern, types::PyModule, PyObject, PyResult, Python, ToPyObject};
|
||||
use pyo3_asyncio::tokio::into_future;
|
||||
|
||||
pub use crate::internal::drivers::rtk::{Firmware, Patch};
|
||||
|
||||
/// Driver for a Realtek controller
|
||||
pub struct Driver(PyObject);
|
||||
|
||||
impl Driver {
|
||||
/// Locate the driver for the provided host.
|
||||
pub async fn for_host(host: &Host, force: bool) -> PyResult<Option<Self>> {
|
||||
Python::with_gil(|py| {
|
||||
PyModule::import(py, intern!(py, "bumble.drivers.rtk"))?
|
||||
.getattr(intern!(py, "Driver"))?
|
||||
.call_method1(intern!(py, "for_host"), (&host.obj, force))
|
||||
.and_then(into_future)
|
||||
})?
|
||||
.await
|
||||
.map(|obj| obj.into_option(Self))
|
||||
}
|
||||
|
||||
/// Check if the host has a known driver.
|
||||
pub async fn check(host: &Host) -> PyResult<bool> {
|
||||
Python::with_gil(|py| {
|
||||
PyModule::import(py, intern!(py, "bumble.drivers.rtk"))?
|
||||
.getattr(intern!(py, "Driver"))?
|
||||
.call_method1(intern!(py, "check"), (&host.obj,))
|
||||
.and_then(|obj| obj.extract::<bool>())
|
||||
})
|
||||
}
|
||||
|
||||
/// Find the [DriverInfo] for the host, if one matches
|
||||
pub async fn driver_info_for_host(host: &Host) -> PyResult<Option<DriverInfo>> {
|
||||
Python::with_gil(|py| {
|
||||
PyModule::import(py, intern!(py, "bumble.drivers.rtk"))?
|
||||
.getattr(intern!(py, "Driver"))?
|
||||
.call_method1(intern!(py, "driver_info_for_host"), (&host.obj,))
|
||||
.and_then(into_future)
|
||||
})?
|
||||
.await
|
||||
.map(|obj| obj.into_option(DriverInfo))
|
||||
}
|
||||
|
||||
/// Send a command to the device to drop firmware
|
||||
pub async fn drop_firmware(host: &mut Host) -> PyResult<()> {
|
||||
Python::with_gil(|py| {
|
||||
PyModule::import(py, intern!(py, "bumble.drivers.rtk"))?
|
||||
.getattr(intern!(py, "Driver"))?
|
||||
.call_method1(intern!(py, "drop_firmware"), (&host.obj,))
|
||||
.and_then(into_future)
|
||||
})?
|
||||
.await
|
||||
.map(|_| ())
|
||||
}
|
||||
|
||||
/// Load firmware onto the device.
|
||||
pub async fn download_firmware(&mut self) -> PyResult<()> {
|
||||
Python::with_gil(|py| {
|
||||
self.0
|
||||
.call_method0(py, intern!(py, "download_firmware"))
|
||||
.and_then(|coroutine| into_future(coroutine.as_ref(py)))
|
||||
})?
|
||||
.await
|
||||
.map(|_| ())
|
||||
}
|
||||
}
|
||||
|
||||
/// Metadata about a known driver & applicable device
|
||||
pub struct DriverInfo(PyObject);
|
||||
|
||||
impl DriverInfo {
|
||||
/// Returns a list of all drivers that Bumble knows how to handle.
|
||||
pub fn all_drivers() -> PyResult<Vec<DriverInfo>> {
|
||||
Python::with_gil(|py| {
|
||||
PyModule::import(py, intern!(py, "bumble.drivers.rtk"))?
|
||||
.getattr(intern!(py, "Driver"))?
|
||||
.getattr(intern!(py, "DRIVER_INFOS"))?
|
||||
.iter()?
|
||||
.map(|r| r.map(|h| DriverInfo(h.to_object(py))))
|
||||
.collect::<PyResult<Vec<_>>>()
|
||||
})
|
||||
}
|
||||
|
||||
/// The firmware file name to load from the filesystem, e.g. `foo_fw.bin`.
|
||||
pub fn firmware_name(&self) -> PyResult<String> {
|
||||
Python::with_gil(|py| {
|
||||
self.0
|
||||
.getattr(py, intern!(py, "fw_name"))?
|
||||
.as_ref(py)
|
||||
.extract::<String>()
|
||||
})
|
||||
}
|
||||
|
||||
/// The config file name, if any, to load from the filesystem, e.g. `foo_config.bin`.
|
||||
pub fn config_name(&self) -> PyResult<Option<String>> {
|
||||
Python::with_gil(|py| {
|
||||
let obj = self.0.getattr(py, intern!(py, "config_name"))?;
|
||||
let handle = obj.as_ref(py);
|
||||
|
||||
if handle.is_none() {
|
||||
Ok(None)
|
||||
} else {
|
||||
handle
|
||||
.extract::<String>()
|
||||
.map(|s| if s.is_empty() { None } else { Some(s) })
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/// Whether or not config is required.
|
||||
pub fn config_needed(&self) -> PyResult<bool> {
|
||||
Python::with_gil(|py| {
|
||||
self.0
|
||||
.getattr(py, intern!(py, "config_needed"))?
|
||||
.as_ref(py)
|
||||
.extract::<bool>()
|
||||
})
|
||||
}
|
||||
|
||||
/// ROM id
|
||||
pub fn rom(&self) -> PyResult<u32> {
|
||||
Python::with_gil(|py| self.0.getattr(py, intern!(py, "rom"))?.as_ref(py).extract())
|
||||
}
|
||||
}
|
||||
@@ -15,7 +15,40 @@
|
||||
//! HCI
|
||||
|
||||
use itertools::Itertools as _;
|
||||
use pyo3::{exceptions::PyException, intern, types::PyModule, PyErr, PyObject, PyResult, Python};
|
||||
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()
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// A Bluetooth address
|
||||
pub struct Address(pub(crate) PyObject);
|
||||
|
||||
71
rust/src/wrapper/host.rs
Normal file
71
rust/src/wrapper/host.rs
Normal file
@@ -0,0 +1,71 @@
|
||||
// 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.
|
||||
|
||||
//! Host-side types
|
||||
|
||||
use crate::wrapper::transport::{Sink, Source};
|
||||
use pyo3::{intern, prelude::PyModule, types::PyDict, PyObject, PyResult, Python};
|
||||
|
||||
/// Host HCI commands
|
||||
pub struct Host {
|
||||
pub(crate) obj: PyObject,
|
||||
}
|
||||
|
||||
impl Host {
|
||||
/// Create a Host that wraps the provided obj
|
||||
pub(crate) fn from(obj: PyObject) -> Self {
|
||||
Self { obj }
|
||||
}
|
||||
|
||||
/// Create a new Host
|
||||
pub fn new(source: Source, sink: Sink) -> PyResult<Self> {
|
||||
Python::with_gil(|py| {
|
||||
PyModule::import(py, intern!(py, "bumble.host"))?
|
||||
.getattr(intern!(py, "Host"))?
|
||||
.call((source.0, sink.0), None)
|
||||
.map(|any| Self { obj: any.into() })
|
||||
})
|
||||
}
|
||||
|
||||
/// Send a reset command and perform other reset tasks.
|
||||
pub async fn reset(&mut self, driver_factory: DriverFactory) -> PyResult<()> {
|
||||
Python::with_gil(|py| {
|
||||
let kwargs = match driver_factory {
|
||||
DriverFactory::None => {
|
||||
let kw = PyDict::new(py);
|
||||
kw.set_item("driver_factory", py.None())?;
|
||||
Some(kw)
|
||||
}
|
||||
DriverFactory::Auto => {
|
||||
// leave the default in place
|
||||
None
|
||||
}
|
||||
};
|
||||
self.obj
|
||||
.call_method(py, intern!(py, "reset"), (), kwargs)
|
||||
.and_then(|coroutine| pyo3_asyncio::tokio::into_future(coroutine.as_ref(py)))
|
||||
})?
|
||||
.await
|
||||
.map(|_| ())
|
||||
}
|
||||
}
|
||||
|
||||
/// Driver factory to use when initializing a host
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum DriverFactory {
|
||||
/// Do not load drivers
|
||||
None,
|
||||
/// Load appropriate driver, if any is found
|
||||
Auto,
|
||||
}
|
||||
92
rust/src/wrapper/l2cap.rs
Normal file
92
rust/src/wrapper/l2cap.rs
Normal file
@@ -0,0 +1,92 @@
|
||||
// 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()
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -31,14 +31,17 @@ 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;
|
||||
|
||||
/// Convenience extensions to [PyObject]
|
||||
pub trait PyObjectExt {
|
||||
pub trait PyObjectExt: Sized {
|
||||
/// Get a GIL-bound reference
|
||||
fn gil_ref<'py>(&'py self, py: Python<'py>) -> &'py PyAny;
|
||||
|
||||
@@ -49,6 +52,17 @@ pub trait PyObjectExt {
|
||||
{
|
||||
Python::with_gil(|py| self.gil_ref(py).extract::<T>())
|
||||
}
|
||||
|
||||
/// If the Python object is a Python `None`, return a Rust `None`, otherwise `Some` with the mapped type
|
||||
fn into_option<T>(self, map_obj: impl Fn(Self) -> T) -> Option<T> {
|
||||
Python::with_gil(|py| {
|
||||
if self.gil_ref(py).is_none() {
|
||||
None
|
||||
} else {
|
||||
Some(map_obj(self))
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
impl PyObjectExt for PyObject {
|
||||
@@ -57,6 +71,21 @@ 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`.
|
||||
|
||||
@@ -14,7 +14,10 @@
|
||||
|
||||
//! GATT profiles
|
||||
|
||||
use crate::wrapper::gatt_client::{CharacteristicProxy, ProfileServiceProxy};
|
||||
use crate::wrapper::{
|
||||
gatt_client::{CharacteristicProxy, ProfileServiceProxy},
|
||||
PyObjectExt,
|
||||
};
|
||||
use pyo3::{intern, PyObject, PyResult, Python};
|
||||
|
||||
/// Exposes the battery GATT service
|
||||
@@ -26,13 +29,7 @@ impl BatteryServiceProxy {
|
||||
Python::with_gil(|py| {
|
||||
self.0
|
||||
.getattr(py, intern!(py, "battery_level"))
|
||||
.map(|level| {
|
||||
if level.is_none(py) {
|
||||
None
|
||||
} else {
|
||||
Some(CharacteristicProxy(level))
|
||||
}
|
||||
})
|
||||
.map(|level| level.into_option(CharacteristicProxy))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -40,6 +40,7 @@ install_requires =
|
||||
humanize >= 4.6.0; platform_system!='Emscripten'
|
||||
libusb1 >= 2.0.1; platform_system!='Emscripten'
|
||||
libusb-package == 1.0.26.1; platform_system!='Emscripten'
|
||||
platformdirs == 3.10.0; platform_system!='Emscripten'
|
||||
prompt_toolkit >= 3.0.16; platform_system!='Emscripten'
|
||||
prettytable >= 3.6.0; platform_system!='Emscripten'
|
||||
protobuf >= 3.12.4; platform_system!='Emscripten'
|
||||
|
||||
@@ -67,8 +67,9 @@ def download_file(base_url, name, remove_suffix):
|
||||
@click.command
|
||||
@click.option(
|
||||
"--output-dir",
|
||||
default=".",
|
||||
help="Output directory where the files will be saved",
|
||||
default="",
|
||||
help="Output directory where the files will be saved. Defaults to the OS-specific"
|
||||
"app data dir, which the driver will check when trying to find firmware",
|
||||
show_default=True,
|
||||
)
|
||||
@click.option(
|
||||
@@ -84,7 +85,10 @@ def main(output_dir, source, single, force, parse):
|
||||
"""Download RTK firmware images and configs."""
|
||||
|
||||
# Check that the output dir exists
|
||||
output_dir = pathlib.Path(output_dir)
|
||||
if output_dir == '':
|
||||
output_dir = rtk.rtk_firmware_dir()
|
||||
else:
|
||||
output_dir = pathlib.Path(output_dir)
|
||||
if not output_dir.is_dir():
|
||||
print("Output dir does not exist or is not a directory")
|
||||
return
|
||||
|
||||
@@ -61,9 +61,8 @@ async def do_load(usb_transport, force):
|
||||
# Get the driver.
|
||||
driver = await rtk.Driver.for_host(host, force)
|
||||
if driver is None:
|
||||
if not force:
|
||||
print("Firmware already loaded or no supported driver for this device.")
|
||||
return
|
||||
print("Firmware already loaded or no supported driver for this device.")
|
||||
return
|
||||
|
||||
await driver.download_firmware()
|
||||
|
||||
|
||||
Reference in New Issue
Block a user