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 runs-on: ubuntu-latest
strategy: strategy:
matrix: 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 fail-fast: false
steps: steps:
- name: Check out from Git - name: Check out from Git
@@ -62,9 +63,15 @@ jobs:
uses: actions-rust-lang/setup-rust-toolchain@v1 uses: actions-rust-lang/setup-rust-toolchain@v1
with: with:
components: clippy,rustfmt components: clippy,rustfmt
- name: Rust Lints toolchain: ${{ matrix.rust-version }}
run: cd rust && cargo fmt --check && cargo clippy --all-targets -- --deny warnings
- name: Rust Build - 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 - 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

View File

@@ -21,6 +21,9 @@ like loading firmware after a cold start.
# ----------------------------------------------------------------------------- # -----------------------------------------------------------------------------
import abc import abc
import logging import logging
import pathlib
import platform
import platformdirs
from . import rtk from . import rtk
@@ -66,3 +69,22 @@ async def get_driver_for_host(host):
return driver return driver
return None 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 # When the environment variable is set, don't look elsewhere
return None 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 # Then, look in the package's driver directory
if (path := pathlib.Path(__file__).parent / "rtk_fw" / file_name).is_file(): if (path := pathlib.Path(__file__).parent / "rtk_fw" / file_name).is_file():
logger.debug(f"{file_name} found in package dir") logger.debug(f"{file_name} found in package dir")
@@ -646,3 +651,16 @@ class Driver:
await self.download_firmware() await self.download_firmware()
await self.host.send_command(HCI_Reset_Command(), check_result=True) await self.host.send_command(HCI_Reset_Command(), check_result=True)
logger.info(f"loaded FW image {self.driver_info.fw_name}") 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>"] authors = ["Marshall Pierce <marshallpierce@google.com>"]
keywords = ["bluetooth", "ble"] keywords = ["bluetooth", "ble"]
categories = ["api-bindings", "network-programming"] categories = ["api-bindings", "network-programming"]
rust-version = "1.69.0" rust-version = "1.70.0"
[dependencies] [dependencies]
pyo3 = { version = "0.18.3", features = ["macros"] } pyo3 = { version = "0.18.3", features = ["macros"] }
@@ -23,7 +23,16 @@ hex = "0.4.3"
itertools = "0.11.0" itertools = "0.11.0"
lazy_static = "1.4.0" lazy_static = "1.4.0"
thiserror = "1.0.41" thiserror = "1.0.41"
# CLI
anyhow = { version = "1.0.71", optional = true } 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] [dev-dependencies]
tokio = { version = "1.28.2", features = ["full"] } tokio = { version = "1.28.2", features = ["full"] }
@@ -32,17 +41,25 @@ nix = "0.26.2"
anyhow = "1.0.71" anyhow = "1.0.71"
pyo3 = { version = "0.18.3", features = ["macros", "anyhow"] } pyo3 = { version = "0.18.3", features = ["macros", "anyhow"] }
pyo3-asyncio = { version = "0.18.0", features = ["tokio-runtime", "attributes", "testing"] } 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"] } clap = { version = "4.3.3", features = ["derive"] }
owo-colors = "3.5.0" owo-colors = "3.5.0"
log = "0.4.19" log = "0.4.19"
env_logger = "0.10.0" env_logger = "0.10.0"
rusb = "0.9.2"
rand = "0.8.5" [package.metadata.docs.rs]
rustdoc-args = ["--generate-link-to-definition"]
[[bin]] [[bin]]
name = "gen-assigned-numbers" name = "gen-assigned-numbers"
path = "tools/gen_assigned_numbers.rs" 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 entry point that uses pyo3_asyncio's test harness
[[test]] [[test]]
@@ -53,4 +70,7 @@ harness = false
[features] [features]
anyhow = ["pyo3/anyhow"] anyhow = ["pyo3/anyhow"]
pyo3-asyncio-attributes = ["pyo3-asyncio/attributes"] 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 Method calls are mapped to the equivalent Python, and return types adapted where
relevant. 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 # 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 Run the corresponding `battery_server` Python example, and launch an emulator in
Android Studio (currently, Canary is required) to run netsim. 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 # Development
Run the tests: Run the tests:
@@ -43,7 +53,7 @@ cargo clippy --all-targets
## Code gen ## 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 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 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 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: 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 // See the License for the specific language governing permissions and
// limitations under the License. // limitations under the License.
use bumble::wrapper::transport::Transport; use bumble::wrapper::{drivers::rtk::DriverInfo, transport::Transport};
use nix::sys::stat::Mode; use nix::sys::stat::Mode;
use pyo3::PyResult; use pyo3::PyResult;
@@ -29,3 +29,9 @@ async fn fifo_transport_can_open() -> PyResult<()> {
Ok(()) 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 //! whether it is a Bluetooth device that uses a non-standard Class, or some other
//! type of device (there's no way to tell). //! type of device (there's no way to tell).
use clap::Parser as _;
use itertools::Itertools as _; use itertools::Itertools as _;
use owo_colors::{OwoColorize, Style}; use owo_colors::{OwoColorize, Style};
use rusb::{Device, DeviceDescriptor, Direction, TransferType, UsbContext}; use rusb::{Device, DeviceDescriptor, Direction, TransferType, UsbContext};
@@ -31,15 +30,12 @@ use std::{
collections::{HashMap, HashSet}, collections::{HashMap, HashSet},
time::Duration, time::Duration,
}; };
const USB_DEVICE_CLASS_DEVICE: u8 = 0x00; const USB_DEVICE_CLASS_DEVICE: u8 = 0x00;
const USB_DEVICE_CLASS_WIRELESS_CONTROLLER: u8 = 0xE0; const USB_DEVICE_CLASS_WIRELESS_CONTROLLER: u8 = 0xE0;
const USB_DEVICE_SUBCLASS_RF_CONTROLLER: u8 = 0x01; const USB_DEVICE_SUBCLASS_RF_CONTROLLER: u8 = 0x01;
const USB_DEVICE_PROTOCOL_BLUETOOTH_PRIMARY_CONTROLLER: u8 = 0x01; const USB_DEVICE_PROTOCOL_BLUETOOTH_PRIMARY_CONTROLLER: u8 = 0x01;
fn main() -> anyhow::Result<()> { pub(crate) fn probe(verbose: bool) -> anyhow::Result<()> {
let cli = Cli::parse();
let mut bt_dev_count = 0; let mut bt_dev_count = 0;
let mut device_serials_by_id: HashMap<(u16, u16), HashSet<String>> = HashMap::new(); let mut device_serials_by_id: HashMap<(u16, u16), HashSet<String>> = HashMap::new();
for device in rusb::devices()?.iter() { for device in rusb::devices()?.iter() {
@@ -159,7 +155,7 @@ fn main() -> anyhow::Result<()> {
println!("{:26}{}", " Product:".green(), p); println!("{:26}{}", " Product:".green(), p);
} }
if cli.verbose { if verbose {
print_device_details(&device, &device_desc)?; 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 wrapper;
pub mod adv; 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, core::AdvertisingData,
gatt_client::{ProfileServiceProxy, ServiceProxy}, gatt_client::{ProfileServiceProxy, ServiceProxy},
hci::Address, hci::Address,
host::Host,
transport::{Sink, Source}, transport::{Sink, Source},
ClosureCallback, ClosureCallback, PyObjectExt,
}, },
}; };
use pyo3::types::PyDict; use pyo3::{
use pyo3::{intern, types::PyModule, PyObject, PyResult, Python, ToPyObject}; intern,
types::{PyDict, PyModule},
PyObject, PyResult, Python, ToPyObject,
};
use pyo3_asyncio::tokio::into_future;
use std::path; use std::path;
/// A device that can send/receive HCI frames. /// A device that can send/receive HCI frames.
@@ -65,7 +70,7 @@ impl Device {
Python::with_gil(|py| { Python::with_gil(|py| {
self.0 self.0
.call_method0(py, intern!(py, "power_on")) .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 .await
.map(|_| ()) .map(|_| ())
@@ -76,7 +81,7 @@ impl Device {
Python::with_gil(|py| { Python::with_gil(|py| {
self.0 self.0
.call_method1(py, intern!(py, "connect"), (peer_addr,)) .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 .await
.map(Connection) .map(Connection)
@@ -89,7 +94,7 @@ impl Device {
kwargs.set_item("filter_duplicates", filter_duplicates)?; kwargs.set_item("filter_duplicates", filter_duplicates)?;
self.0 self.0
.call_method(py, intern!(py, "start_scanning"), (), Some(kwargs)) .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 .await
.map(|_| ()) .map(|_| ())
@@ -123,6 +128,15 @@ impl Device {
.map(|_| ()) .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]. /// Start advertising the data set with [Device.set_advertisement].
pub async fn start_advertising(&mut self, auto_restart: bool) -> PyResult<()> { pub async fn start_advertising(&mut self, auto_restart: bool) -> PyResult<()> {
Python::with_gil(|py| { Python::with_gil(|py| {
@@ -131,7 +145,7 @@ impl Device {
self.0 self.0
.call_method(py, intern!(py, "start_advertising"), (), Some(kwargs)) .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 .await
.map(|_| ()) .map(|_| ())
@@ -142,7 +156,7 @@ impl Device {
Python::with_gil(|py| { Python::with_gil(|py| {
self.0 self.0
.call_method0(py, intern!(py, "stop_advertising")) .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 .await
.map(|_| ()) .map(|_| ())
@@ -173,7 +187,7 @@ impl Peer {
Python::with_gil(|py| { Python::with_gil(|py| {
self.0 self.0
.call_method0(py, intern!(py, "discover_services")) .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 .await
.and_then(|list| { .and_then(|list| {
@@ -207,13 +221,7 @@ impl Peer {
let class = module.getattr(P::PROXY_CLASS_NAME)?; let class = module.getattr(P::PROXY_CLASS_NAME)?;
self.0 self.0
.call_method1(py, intern!(py, "create_service_proxy"), (class,)) .call_method1(py, intern!(py, "create_service_proxy"), (class,))
.map(|obj| { .map(|obj| obj.into_option(P::wrap))
if obj.is_none(py) {
None
} else {
Some(P::wrap(obj))
}
})
}) })
} }
} }

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 assigned_numbers;
pub mod core; pub mod core;
pub mod device; pub mod device;
pub mod drivers;
pub mod gatt_client; pub mod gatt_client;
pub mod hci; pub mod hci;
pub mod host;
pub mod logging; pub mod logging;
pub mod profile; pub mod profile;
pub mod transport; pub mod transport;
/// Convenience extensions to [PyObject] /// Convenience extensions to [PyObject]
pub trait PyObjectExt { pub trait PyObjectExt: Sized {
/// Get a GIL-bound reference /// Get a GIL-bound reference
fn gil_ref<'py>(&'py self, py: Python<'py>) -> &'py PyAny; 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>()) 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 { impl PyObjectExt for PyObject {

View File

@@ -14,7 +14,10 @@
//! GATT profiles //! GATT profiles
use crate::wrapper::gatt_client::{CharacteristicProxy, ProfileServiceProxy}; use crate::wrapper::{
gatt_client::{CharacteristicProxy, ProfileServiceProxy},
PyObjectExt,
};
use pyo3::{intern, PyObject, PyResult, Python}; use pyo3::{intern, PyObject, PyResult, Python};
/// Exposes the battery GATT service /// Exposes the battery GATT service
@@ -26,13 +29,7 @@ impl BatteryServiceProxy {
Python::with_gil(|py| { Python::with_gil(|py| {
self.0 self.0
.getattr(py, intern!(py, "battery_level")) .getattr(py, intern!(py, "battery_level"))
.map(|level| { .map(|level| level.into_option(CharacteristicProxy))
if level.is_none(py) {
None
} else {
Some(CharacteristicProxy(level))
}
})
}) })
} }
} }

View File

@@ -40,6 +40,7 @@ install_requires =
humanize >= 4.6.0; platform_system!='Emscripten' humanize >= 4.6.0; platform_system!='Emscripten'
libusb1 >= 2.0.1; platform_system!='Emscripten' libusb1 >= 2.0.1; platform_system!='Emscripten'
libusb-package == 1.0.26.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' prompt_toolkit >= 3.0.16; platform_system!='Emscripten'
prettytable >= 3.6.0; platform_system!='Emscripten' prettytable >= 3.6.0; platform_system!='Emscripten'
protobuf >= 3.12.4; 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.command
@click.option( @click.option(
"--output-dir", "--output-dir",
default=".", default="",
help="Output directory where the files will be saved", 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, show_default=True,
) )
@click.option( @click.option(
@@ -84,7 +85,10 @@ def main(output_dir, source, single, force, parse):
"""Download RTK firmware images and configs.""" """Download RTK firmware images and configs."""
# Check that the output dir exists # 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(): if not output_dir.is_dir():
print("Output dir does not exist or is not a directory") print("Output dir does not exist or is not a directory")
return return

View File

@@ -61,9 +61,8 @@ async def do_load(usb_transport, force):
# Get the driver. # Get the driver.
driver = await rtk.Driver.for_host(host, force) driver = await rtk.Driver.for_host(host, force)
if driver is None: if driver is None:
if not force: print("Firmware already loaded or no supported driver for this device.")
print("Firmware already loaded or no supported driver for this device.") return
return
await driver.download_firmware() await driver.download_firmware()