Merge pull request #261 from marshallpierce/mp/rust-realtek-tools

Rust tools for working with Realtek firmware
This commit is contained in:
Gilles Boccon-Gibod
2023-09-05 10:55:29 -07:00
committed by GitHub
28 changed files with 1881 additions and 164 deletions

View File

@@ -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
# 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

View File

@@ -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
)

View File

@@ -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

832
rust/Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

@@ -10,7 +10,7 @@ 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"] }
@@ -23,7 +23,16 @@ 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 }
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"] }
@@ -32,17 +41,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 +70,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"]
default = []

View File

@@ -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:
@@ -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
```

View File

@@ -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(())
}

View 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.

View 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;

View 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())
}
}

17
rust/src/cli/mod.rs Normal file
View 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.
pub(crate) mod firmware;
pub(crate) mod usb;

View File

@@ -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,
}

View 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;

View 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
View 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;

View File

@@ -29,3 +29,5 @@
pub mod wrapper;
pub mod adv;
pub(crate) mod internal;

179
rust/src/main.rs Normal file
View File

@@ -0,0 +1,179 @@
// 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::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,
},
/// 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 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,
}

View File

@@ -20,12 +20,17 @@ use crate::{
core::AdvertisingData,
gatt_client::{ProfileServiceProxy, ServiceProxy},
hci::Address,
host::Host,
transport::{Sink, Source},
ClosureCallback,
ClosureCallback, PyObjectExt,
},
};
use pyo3::types::PyDict;
use pyo3::{intern, types::PyModule, PyObject, PyResult, Python, ToPyObject};
use pyo3::{
intern,
types::{PyDict, PyModule},
PyObject, PyResult, Python, ToPyObject,
};
use pyo3_asyncio::tokio::into_future;
use std::path;
/// A device that can send/receive HCI frames.
@@ -65,7 +70,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,7 +81,7 @@ 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)
@@ -89,7 +94,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 +128,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 +145,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,7 +156,7 @@ 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(|_| ())
@@ -173,7 +187,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 +221,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))
})
}
}

View 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;

View 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())
}
}

71
rust/src/wrapper/host.rs Normal file
View 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,
}

View File

@@ -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 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 {

View File

@@ -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))
})
}
}

View File

@@ -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'

View File

@@ -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,6 +85,9 @@ def main(output_dir, source, single, force, parse):
"""Download RTK firmware images and configs."""
# Check that the output dir exists
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")

View File

@@ -61,7 +61,6 @@ 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