mirror of
https://github.com/fossasia/badgemagic-rs
synced 2025-06-23 15:23:58 +00:00
Add support for transferring badge config via Bluetooth Low Energy
Adds a new crate feature `ble` that allows for transfering badge configurations over Bluetooth Low Energy instead of USB. Expands the CLI to allow for specification of the transport protocol, either USB or BLE. The BLE library uses async rust requiring to add tokio as a async runtime to the CLI.
This commit is contained in:
parent
9a155a85fb
commit
c273d4cbfc
6 changed files with 1458 additions and 5 deletions
1273
Cargo.lock
generated
1273
Cargo.lock
generated
File diff suppressed because it is too large
Load diff
|
@ -24,15 +24,18 @@ cli = [
|
|||
"embedded-graphics",
|
||||
"serde",
|
||||
"usb-hid",
|
||||
"ble",
|
||||
"dep:base64",
|
||||
"dep:clap",
|
||||
"dep:serde_json",
|
||||
"dep:toml",
|
||||
"dep:tokio",
|
||||
]
|
||||
|
||||
embedded-graphics = ["dep:embedded-graphics"]
|
||||
serde = ["dep:serde"]
|
||||
usb-hid = ["dep:hidapi"]
|
||||
ble = ["dep:btleplug", "dep:async-std"]
|
||||
|
||||
[dependencies]
|
||||
anyhow = "1.0.86"
|
||||
|
@ -40,9 +43,11 @@ base64 = { version = "0.22.1", optional = true }
|
|||
clap = { version = "4.5.8", features = ["derive"], optional = true }
|
||||
embedded-graphics = { version = "0.8.1", optional = true }
|
||||
hidapi = { version = "2.6.1", optional = true }
|
||||
btleplug = { version = "0.11.5", optional = true }
|
||||
async-std = { version = "1.12.0", optional = true }
|
||||
tokio = { version = "1.38.0", features = ["rt", "macros"], optional = true }
|
||||
serde = { version = "1.0.203", features = ["derive"], optional = true }
|
||||
serde_json = { version = "1.0.118", optional = true }
|
||||
time = "0.3.36"
|
||||
toml = { version = "0.8.13", optional = true }
|
||||
zerocopy = { version = "0.7.34", features = ["derive"] }
|
||||
|
||||
|
|
|
@ -46,7 +46,8 @@ cargo run --features cli -- --help
|
|||
|
||||
## Usage
|
||||
|
||||
Execute the `badgemagic` tool and pass the file name of your configuration file. Depending on how you installed the tool:
|
||||
Execute the `badgemagic` tool and pass the file name of your configuration file alongside the mode of transport (USB or Bluetooth Low Energy).
|
||||
Depending on how you installed the tool:
|
||||
|
||||
```sh
|
||||
# Downloaded from release page
|
||||
|
@ -60,6 +61,8 @@ cargo run --features cli -- config.toml
|
|||
```
|
||||
|
||||
The above command will read your configuration from a file named `config.toml` in the current directory.
|
||||
The transport mode can be either `--transport usb` or `--transport ble` for transferring the message via Bluetooth Low Energy.
|
||||
Usage of BLE on macOS requires special permissions, which is explained in more detail [here](https://github.com/deviceplug/btleplug#macos).
|
||||
|
||||
## Configuration
|
||||
|
||||
|
|
153
src/ble.rs
Normal file
153
src/ble.rs
Normal file
|
@ -0,0 +1,153 @@
|
|||
//! Connect to an LED badge via Bluetooth Low Energy (BLE)
|
||||
|
||||
use std::time::Duration;
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
use async_std::task;
|
||||
use btleplug::{
|
||||
api::{Central as _, Manager as _, Peripheral as _, ScanFilter, WriteType},
|
||||
platform::{Manager, Peripheral},
|
||||
};
|
||||
|
||||
use crate::protocol::PayloadBuffer;
|
||||
|
||||
const BADGE_SERVICE_UUID_STR: &str = "0000fee0-0000-1000-8000-00805f9b34fb";
|
||||
const BADGE_CHAR_UUID_STR: &str = "0000fee1-0000-1000-8000-00805f9b34fb";
|
||||
const BADGE_BLE_DEVICE_NAME: &str = "LSLED";
|
||||
const BLE_CHAR_CHUNK_SIZE: usize = 16;
|
||||
|
||||
/// A discovered BLE device
|
||||
pub struct Device {
|
||||
peripheral: Peripheral,
|
||||
}
|
||||
|
||||
impl Device {
|
||||
/// Return all supported devices that are found in two seconds.
|
||||
///
|
||||
/// Returns all badges that are in BLE range and are in Bluetooth transfer mode.
|
||||
pub async fn enumerate() -> Result<Vec<Self>> {
|
||||
Self::enumerate_duration(Duration::from_secs(2)).await
|
||||
}
|
||||
|
||||
/// Return all supported devices that are found in the given duration.
|
||||
///
|
||||
/// Returns all badges that are in BLE range and are in Bluetooth transfer mode.
|
||||
/// # Panics
|
||||
/// This function panics if it is unable to access the Bluetooth adapter.
|
||||
pub async fn enumerate_duration(scan_duration: Duration) -> Result<Vec<Self>> {
|
||||
// Run device scan
|
||||
let manager = Manager::new().await.unwrap();
|
||||
let adapter = manager.adapters().await.unwrap().pop().unwrap();
|
||||
adapter.start_scan(ScanFilter::default()).await?;
|
||||
task::sleep(scan_duration).await;
|
||||
|
||||
// Filter for badge devices
|
||||
let mut led_badges = vec![];
|
||||
for p in adapter.peripherals().await? {
|
||||
if Self::is_badge_device(&p).await {
|
||||
led_badges.push(p);
|
||||
}
|
||||
}
|
||||
|
||||
led_badges
|
||||
.into_iter()
|
||||
.map(|p| Ok(Self { peripheral: p }))
|
||||
.collect()
|
||||
}
|
||||
|
||||
async fn is_badge_device(peripheral: &Peripheral) -> bool {
|
||||
// Check whether the BLE device has the service UUID we're looking for
|
||||
// and also the correct name.
|
||||
// The service uuid is also by devices that are not LED badges, so
|
||||
// the name check is also necessary.
|
||||
let props = peripheral.properties().await;
|
||||
if props.is_err() {
|
||||
return false;
|
||||
}
|
||||
|
||||
if let Some(props) = props.unwrap() {
|
||||
if props.local_name.is_none() {
|
||||
return false;
|
||||
}
|
||||
|
||||
if props.local_name.as_ref().unwrap() != BADGE_BLE_DEVICE_NAME {
|
||||
return false;
|
||||
}
|
||||
|
||||
props
|
||||
.services
|
||||
.iter()
|
||||
.any(|uuid| uuid.to_string() == BADGE_SERVICE_UUID_STR)
|
||||
} else {
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
/// Return the single supported device
|
||||
///
|
||||
/// This function returns an error if no device could be found
|
||||
/// or if multiple devices would match.
|
||||
pub async fn single() -> Result<Self> {
|
||||
let mut devices = Self::enumerate().await?.into_iter();
|
||||
let device = devices.next().context("no device found")?;
|
||||
anyhow::ensure!(devices.next().is_none(), "multiple devices found");
|
||||
Ok(device)
|
||||
}
|
||||
|
||||
/// Write a payload to the device.
|
||||
///
|
||||
/// This function connects to the device, writes the payload and disconnects.
|
||||
/// When the device went out of range between discovering it
|
||||
/// and writing the payload, an error is returned.
|
||||
/// # Panics
|
||||
/// This functions panics if the BLE device does not have the expected badge characteristic.
|
||||
pub async fn write(&self, payload: PayloadBuffer) -> Result<()> {
|
||||
// Connect and discover services
|
||||
self.peripheral.connect().await?;
|
||||
if let Err(error) = self.peripheral.discover_services().await {
|
||||
self.peripheral.disconnect().await?;
|
||||
return Err(error.into());
|
||||
}
|
||||
|
||||
// Get characteristics
|
||||
let characteristics = self.peripheral.characteristics();
|
||||
let badge_char = characteristics
|
||||
.iter()
|
||||
.find(|c| c.uuid.to_string() == BADGE_CHAR_UUID_STR);
|
||||
|
||||
if badge_char.is_none() {
|
||||
return Err(anyhow::anyhow!("Badge characteristic not found"));
|
||||
}
|
||||
let badge_char = badge_char.unwrap();
|
||||
|
||||
// Write payload
|
||||
let bytes = payload.into_padded_bytes();
|
||||
let data = bytes.as_ref();
|
||||
|
||||
anyhow::ensure!(
|
||||
data.len() % BLE_CHAR_CHUNK_SIZE == 0,
|
||||
format!(
|
||||
"Payload size must be a multiple of {} bytes",
|
||||
BLE_CHAR_CHUNK_SIZE
|
||||
)
|
||||
);
|
||||
|
||||
// the device will brick itself if the payload is too long (more then 8192 bytes)
|
||||
anyhow::ensure!(data.len() <= 8192, "payload too long (max 8192 bytes)");
|
||||
|
||||
for chunk in data.chunks(BLE_CHAR_CHUNK_SIZE) {
|
||||
let write_result = self
|
||||
.peripheral
|
||||
.write(badge_char, chunk, WriteType::WithoutResponse)
|
||||
.await;
|
||||
|
||||
if let Err(error) = write_result {
|
||||
self.peripheral.disconnect().await?;
|
||||
return Err(anyhow::anyhow!("Error writing payload chunk: {:?}", error));
|
||||
}
|
||||
}
|
||||
|
||||
self.peripheral.disconnect().await?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
|
@ -6,6 +6,9 @@ pub mod protocol;
|
|||
#[cfg(feature = "usb-hid")]
|
||||
pub mod usb_hid;
|
||||
|
||||
#[cfg(feature = "ble")]
|
||||
pub mod ble;
|
||||
|
||||
#[cfg(feature = "embedded-graphics")]
|
||||
pub mod util;
|
||||
|
||||
|
|
22
src/main.rs
22
src/main.rs
|
@ -4,8 +4,9 @@ use std::{fs, path::PathBuf};
|
|||
|
||||
use anyhow::{Context, Result};
|
||||
use badgemagic::{
|
||||
ble::Device as BleDevice,
|
||||
protocol::{Mode, PayloadBuffer, Speed, Style},
|
||||
usb_hid::Device,
|
||||
usb_hid::Device as UsbDevice,
|
||||
};
|
||||
use base64::Engine;
|
||||
use clap::Parser;
|
||||
|
@ -38,10 +39,21 @@ struct Args {
|
|||
#[clap(long)]
|
||||
format: Option<String>,
|
||||
|
||||
/// Transport protocol to use
|
||||
#[clap(long)]
|
||||
transport: TransportProtocol,
|
||||
|
||||
/// Path to TOML configuration file
|
||||
config: PathBuf,
|
||||
}
|
||||
|
||||
#[derive(Clone, Deserialize, clap::ValueEnum)]
|
||||
#[serde(rename_all = "kebab-case")]
|
||||
enum TransportProtocol {
|
||||
Usb,
|
||||
Ble,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
struct Config {
|
||||
|
@ -78,7 +90,8 @@ enum Content {
|
|||
// PngFile { png_file: PathBuf },
|
||||
}
|
||||
|
||||
fn main() -> Result<()> {
|
||||
#[tokio::main(flavor = "current_thread")]
|
||||
async fn main() -> Result<()> {
|
||||
let args = Args::parse();
|
||||
let config = fs::read_to_string(&args.config)
|
||||
.with_context(|| format!("load config: {:?}", args.config))?;
|
||||
|
@ -177,7 +190,10 @@ fn main() -> Result<()> {
|
|||
}
|
||||
}
|
||||
|
||||
Device::single()?.write(payload)?;
|
||||
match args.transport {
|
||||
TransportProtocol::Usb => UsbDevice::single()?.write(payload),
|
||||
TransportProtocol::Ble => BleDevice::single().await?.write(payload).await,
|
||||
}?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
|
Loading…
Reference in a new issue