Compare commits

..

No commits in common. "main" and "commit-d0149fc" have entirely different histories.

9 changed files with 68 additions and 1084 deletions

View file

@ -51,7 +51,7 @@ jobs:
run: |
rustup toolchain install ${{ matrix.rust }} --profile minimal --no-self-update
- name: Install build dependencies
run: sudo apt-get install -y libudev-dev libdbus-1-dev
run: sudo apt-get install -y libudev-dev
- name: ${{ matrix.cmd.name }}
run: ${{ matrix.cmd.run }} ${{ matrix.features }} -- ${{ matrix.cmd.run2 }}
@ -66,7 +66,7 @@ jobs:
runs-on: ubuntu-latest
target: x86_64-unknown-linux-gnu
pre-build: |
sudo apt-get install -y libudev-dev libdbus-1-dev
sudo apt-get install -y libudev-dev
- name: Windows (x86_64)
runs-on: windows-latest
target: x86_64-pc-windows-msvc

805
Cargo.lock generated

File diff suppressed because it is too large Load diff

View file

@ -24,7 +24,6 @@ cli = [
"embedded-graphics",
"serde",
"usb-hid",
"ble",
"dep:base64",
"dep:clap",
"dep:serde_json",
@ -34,19 +33,16 @@ cli = [
embedded-graphics = ["dep:embedded-graphics"]
serde = ["dep:serde"]
usb-hid = ["dep:hidapi"]
ble = ["dep:btleplug", "dep:uuid", "dep:tokio"]
[dependencies]
anyhow = "1.0.95"
anyhow = "1.0.86"
base64 = { version = "0.22.1", optional = true }
clap = { version = "4.5.23", features = ["derive"], optional = true }
clap = { version = "4.5.4", features = ["derive"], optional = true }
embedded-graphics = { version = "0.8.1", optional = true }
hidapi = { version = "2.6.3", optional = true }
btleplug = { version = "0.11.6", optional = true }
uuid = { version = "1.11.0", optional = true }
tokio = { version = "1.39.2", features = ["rt"], optional = true }
serde = { version = "1.0.217", features = ["derive"], optional = true }
serde_json = { version = "1.0.134", optional = true }
time = "0.3.37"
toml = { version = "0.8.19", optional = true }
zerocopy = { version = "0.8.14", features = ["derive"] }
hidapi = { version = "2.6.1", optional = true }
serde = { version = "1.0.203", features = ["derive"], optional = true }
serde_json = { version = "1.0.117", optional = true }
time = "0.3.36"
toml = { version = "0.8.13", optional = true }
zerocopy = { version = "0.7.34", features = ["derive"] }

View file

@ -46,8 +46,7 @@ cargo run --features cli -- --help
## Usage
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:
Execute the `badgemagic` tool and pass the file name of your configuration file. Depending on how you installed the tool:
```sh
# Downloaded from release page
@ -61,8 +60,6 @@ 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

View file

@ -1,206 +0,0 @@
//! Connect to an LED badge via Bluetooth Low Energy (BLE)
use std::time::Duration;
use anyhow::{Context, Result};
use btleplug::{
api::{bleuuid, Central as _, Manager as _, Peripheral as _, ScanFilter, WriteType},
platform::{Manager, Peripheral},
};
use tokio::time;
use uuid::Uuid;
use crate::protocol::PayloadBuffer;
/// `0000fee0-0000-1000-8000-00805f9b34fb`
const BADGE_SERVICE_UUID: Uuid = bleuuid::uuid_from_u16(0xfee0);
/// `0000fee1-0000-1000-8000-00805f9b34fb`
const BADGE_CHAR_UUID: Uuid = bleuuid::uuid_from_u16(0xfee1);
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 a list of all BLE devies as a string representation.
pub async fn list_all() -> Result<Vec<String>> {
// Run device scan
let manager = Manager::new().await.context("create BLE manager")?;
let adapters = manager
.adapters()
.await
.context("enumerate bluetooth adapters")?;
let adapter = adapters.first().context("no bluetooth adapter found")?;
adapter
.start_scan(ScanFilter {
// don't filter by service
services: Vec::new(),
})
.await
.context("bluetooth scan start")?;
time::sleep(Duration::from_secs(2)).await;
let mut devices = Vec::new();
for peripheral in adapter
.peripherals()
.await
.context("enumerating bluetooth devices")?
{
let device = async {
let props = peripheral
.properties()
.await?
.context("missing device info")?;
Ok(format!(
"{}: name={:?} services={:?}",
props.address, props.local_name, props.services
))
};
devices.push(device.await.unwrap_or_else(|err: anyhow::Error| {
format!("{} failed to collect info: {err:?}", peripheral.address())
}));
}
Ok(devices)
}
/// 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.context("create BLE manager")?;
let adapters = manager
.adapters()
.await
.context("enumerate bluetooth adapters")?;
let adapter = adapters.first().context("no bluetooth adapter found")?;
adapter
.start_scan(ScanFilter {
services: vec![BADGE_SERVICE_UUID],
})
.await
.context("bluetooth scan start")?;
time::sleep(scan_duration).await;
// Filter for badge devices
let mut led_badges = vec![];
for p in adapter
.peripherals()
.await
.context("enumerating bluetooth devices")?
{
if let Some(badge) = Self::from_peripheral(p).await {
led_badges.push(badge);
}
}
Ok(led_badges)
}
async fn from_peripheral(peripheral: Peripheral) -> Option<Self> {
// The existance of the service with the correct UUID
// exists is already checked by the scan filter.
// But we also need to check the device name to make sure
// we're talking to a badge as some devices that are not led badges
// also use the same service UUID.
let props = peripheral.properties().await.ok()??;
let local_name = props.local_name.as_ref()?;
if local_name == BADGE_BLE_DEVICE_NAME {
Some(Self { peripheral })
} else {
None
}
}
/// 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
.context("enumerating badges")?
.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<()> {
self.peripheral
.connect()
.await
.context("bluetooth device connect")?;
let result = self.write_connected(payload).await;
let disconnect_result = self.peripheral.disconnect().await;
if result.is_ok() {
// Write succesful, return disconnect result
Ok(disconnect_result?)
} else {
// Write failed, return write result and ignore disconnect result
result
}
}
async fn write_connected(&self, payload: PayloadBuffer) -> Result<()> {
// Get characteristic
self.peripheral
.discover_services()
.await
.context("discovering services")?;
let characteristics = self.peripheral.characteristics();
let badge_char = characteristics
.iter()
.find(|c| c.uuid == BADGE_CHAR_UUID)
.context("badge characteristic not found")?;
// Write payload
let bytes = payload.into_padded_bytes();
let data = bytes.as_ref();
anyhow::ensure!(
data.len() % BLE_CHAR_CHUNK_SIZE == 0,
"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) {
self.peripheral
.write(badge_char, chunk, WriteType::WithoutResponse)
.await
.context("writing payload chunk")?;
}
Ok(())
}
}

View file

@ -6,9 +6,6 @@ pub mod protocol;
#[cfg(feature = "usb-hid")]
pub mod usb_hid;
#[cfg(feature = "ble")]
pub mod ble;
#[cfg(feature = "embedded-graphics")]
pub mod util;

View file

@ -4,12 +4,11 @@ use std::{fs, path::PathBuf};
use anyhow::{Context, Result};
use badgemagic::{
ble::Device as BleDevice,
protocol::{Mode, PayloadBuffer, Speed, Style},
usb_hid::Device as UsbDevice,
usb_hid::Device,
};
use base64::Engine;
use clap::{Parser, ValueEnum};
use clap::Parser;
use embedded_graphics::{
geometry::Point,
image::{Image, ImageRawLE},
@ -39,24 +38,8 @@ struct Args {
#[clap(long)]
format: Option<String>,
/// Transport protocol to use
#[clap(long)]
transport: TransportProtocol,
/// List all devices visible to a transport and exit
#[clap(long)]
list_devices: bool,
/// Path to TOML configuration file
#[clap(required_unless_present = "list_devices")]
config: Option<PathBuf>,
}
#[derive(Clone, Deserialize, ValueEnum)]
#[serde(rename_all = "kebab-case")]
enum TransportProtocol {
Usb,
Ble,
config: PathBuf,
}
#[derive(Deserialize)]
@ -96,48 +79,16 @@ enum Content {
}
fn main() -> Result<()> {
let mut args = Args::parse();
let args = Args::parse();
let config = fs::read_to_string(&args.config)
.with_context(|| format!("load config: {:?}", args.config))?;
if args.list_devices {
return list_devices(&args.transport);
}
let payload = gnerate_payload(&mut args)?;
write_payload(&args.transport, payload)
}
fn list_devices(transport: &TransportProtocol) -> Result<()> {
let devices = match transport {
TransportProtocol::Usb => UsbDevice::list_all(),
TransportProtocol::Ble => tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()?
.block_on(async { BleDevice::list_all().await }),
}?;
eprintln!(
"found {} {} devices",
devices.len(),
transport.to_possible_value().unwrap().get_name(),
);
for device in devices {
println!("- {device}");
}
Ok(())
}
fn gnerate_payload(args: &mut Args) -> Result<PayloadBuffer> {
let config_path = args.config.take().unwrap_or_default();
let config = fs::read_to_string(&config_path)
.with_context(|| format!("load config: {config_path:?}"))?;
let config: Config = {
let extension = args
.format
.as_deref()
.map(AsRef::as_ref)
.or(config_path.extension())
.or(args.config.extension())
.context("missing file extension for config file")?;
match extension.to_str().unwrap_or_default() {
"json" => serde_json::from_str(&config).context("parse config")?,
@ -226,18 +177,7 @@ fn gnerate_payload(args: &mut Args) -> Result<PayloadBuffer> {
}
}
Ok(payload)
}
Device::single()?.write(payload)?;
fn write_payload(
transport: &TransportProtocol,
payload: PayloadBuffer,
) -> Result<(), anyhow::Error> {
match transport {
TransportProtocol::Usb => UsbDevice::single()?.write(payload),
TransportProtocol::Ble => tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()?
.block_on(async { BleDevice::single().await?.write(payload).await }),
}
Ok(())
}

View file

@ -12,7 +12,7 @@ use embedded_graphics::{
Drawable,
};
use time::OffsetDateTime;
use zerocopy::{BigEndian, FromBytes, Immutable, IntoBytes, KnownLayout, U16};
use zerocopy::{AsBytes, BigEndian, FromBytes, FromZeroes, U16};
/// Message style configuration
/// ```
@ -197,7 +197,7 @@ const MSG_PADDING_ALIGN: usize = 64;
const MAGIC: [u8; 6] = *b"wang\0\0";
#[derive(FromBytes, IntoBytes, Immutable, KnownLayout)]
#[derive(FromZeroes, FromBytes, AsBytes)]
#[repr(C)]
struct Header {
magic: [u8; 6],
@ -210,7 +210,7 @@ struct Header {
_padding_2: [u8; 20],
}
#[derive(FromBytes, IntoBytes, Immutable, KnownLayout)]
#[derive(FromZeroes, FromBytes, AsBytes)]
#[repr(C)]
struct Timestamp {
year: u8,
@ -297,7 +297,7 @@ impl PayloadBuffer {
}
fn header_mut(&mut self) -> &mut Header {
Header::mut_from_prefix(&mut self.data).unwrap().0
Header::mut_from_prefix(&mut self.data).unwrap()
}
/// Return the current number of messages
@ -359,7 +359,7 @@ impl PayloadBuffer {
let start = self.data.len();
self.data.resize(start + count * 11, 0);
MessageBuffer(FromBytes::mut_from_bytes(&mut self.data[start..]).unwrap())
MessageBuffer(FromBytes::mut_slice_from(&mut self.data[start..]).unwrap())
}
/// Get the current payload as bytes (without padding)

View file

@ -29,25 +29,6 @@ pub struct Device {
}
impl Device {
/// Return a list of all usb devies as a string representation
pub fn list_all() -> Result<Vec<String>> {
let api = HidApi::new().context("create hid api")?;
let devices = api.device_list();
Ok(devices
.map(|info| {
format!(
"{:?}: vendor_id={:#06x} product_id={:#06x} manufacturer={:?} product={:?}",
info.path(),
info.vendor_id(),
info.product_id(),
info.manufacturer_string(),
info.product_string(),
)
})
.collect())
}
/// Return all supported devices
pub fn enumerate() -> Result<Vec<Self>> {
let api = HidApi::new().context("create hid api")?;