This commit is contained in:
Valentin Weber 2025-07-10 18:00:09 +02:00 committed by GitHub
commit f5f5a8a237
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
9 changed files with 1210 additions and 143 deletions

1159
Cargo.lock generated

File diff suppressed because it is too large Load diff

View file

@ -1,7 +1,7 @@
[package] [package]
name = "badgemagic" name = "badgemagic"
version = "0.1.0" version = "0.2.0"
authors = ["Martin Michaelis <code@mgjm.de>"] authors = ["Martin Michaelis <code@mgjm.de>", "Valentin Weber <weva+code@kabelsalat.ch>"]
edition = "2021" edition = "2021"
description = "Badge Magic with LEDs - Library and CLI" description = "Badge Magic with LEDs - Library and CLI"
homepage = "https://badgemagic.fossasia.org" homepage = "https://badgemagic.fossasia.org"
@ -35,18 +35,21 @@ embedded-graphics = ["dep:embedded-graphics"]
serde = ["dep:serde"] serde = ["dep:serde"]
usb-hid = ["dep:hidapi"] usb-hid = ["dep:hidapi"]
ble = ["dep:btleplug", "dep:uuid", "dep:tokio"] ble = ["dep:btleplug", "dep:uuid", "dep:tokio"]
u8g2-fonts = ["dep:u8g2-fonts"]
[dependencies] [dependencies]
anyhow = "1.0.95" anyhow = "1.0.98"
base64 = { version = "0.22.1", optional = true } base64 = { version = "0.22.1", optional = true }
clap = { version = "4.5.23", features = ["derive"], optional = true } clap = { version = "4.5.40", features = ["derive"], optional = true }
embedded-graphics = { version = "0.8.1", optional = true } embedded-graphics = { version = "0.8.1", optional = true }
hidapi = { version = "2.6.3", optional = true } hidapi = { version = "2.6.3", optional = true }
btleplug = { version = "0.11.6", optional = true } btleplug = { version = "0.11.8", optional = true }
uuid = { version = "1.11.0", optional = true } uuid = { version = "1.17.0", optional = true }
tokio = { version = "1.39.2", features = ["rt"], optional = true } tokio = { version = "1.46.1", features = ["rt"], optional = true }
serde = { version = "1.0.217", features = ["derive"], optional = true } serde = { version = "1.0.219", features = ["derive"], optional = true }
serde_json = { version = "1.0.134", optional = true } serde_json = { version = "1.0.140", optional = true }
time = "0.3.37" time = "0.3.41"
toml = { version = "0.8.19", optional = true } toml = { version = "0.9.0", optional = true }
zerocopy = { version = "0.8.14", features = ["derive"] } zerocopy = { version = "0.8.14", features = ["derive"] }
u8g2-fonts = { version = "0.7.1", features = ["embedded_graphics_textstyle"], optional = true }
image = "0.25.6"

View file

@ -1,4 +1,5 @@
Copyright 2024 Martin Michaelis Copyright 2024 Martin Michaelis
Copyright 2025 Valentin Weber
Permission is hereby granted, free of charge, to any person obtaining a copy Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the “Software”), to deal of this software and associated documentation files (the “Software”), to deal

View file

@ -28,7 +28,7 @@ chmod +x badgemagic
./badgemagic --help ./badgemagic --help
``` ```
> Note: The windows and macOS build is not actively tested. Please try it out and report back whether it worked or any problems that might occour. > Note: The windows and macOS build is not actively tested. Please try it out and report back whether it worked or any problems that might occur.
If your system is not listed above (Linux / Windows on ARM, musl, etc.) or you want to do it anyway, you can install this tool from source: If your system is not listed above (Linux / Windows on ARM, musl, etc.) or you want to do it anyway, you can install this tool from source:

View file

@ -5,13 +5,13 @@ use badgemagic::{
embedded_graphics::{ embedded_graphics::{
geometry::Point, mono_font::MonoTextStyle, pixelcolor::BinaryColor, text::Text, geometry::Point, mono_font::MonoTextStyle, pixelcolor::BinaryColor, text::Text,
}, },
protocol::{Mode, PayloadBuffer, Style}, protocol::{Brightness, Mode, PayloadBuffer, Style},
usb_hid::Device, usb_hid::Device,
util::DrawableLayoutExt, util::DrawableLayoutExt,
}; };
fn main() -> Result<()> { fn main() -> Result<()> {
let mut payload = PayloadBuffer::new(); let mut payload = PayloadBuffer::new(Brightness::default());
payload.add_message_drawable( payload.add_message_drawable(
Style::default().mode(Mode::Center), Style::default().mode(Mode::Center),

View file

@ -191,7 +191,7 @@ impl Device {
BLE_CHAR_CHUNK_SIZE BLE_CHAR_CHUNK_SIZE
); );
// the device will brick itself if the payload is too long (more then 8192 bytes) // the device will brick itself if the payload is too long (more than 8192 bytes)
anyhow::ensure!(data.len() <= 8192, "payload too long (max 8192 bytes)"); anyhow::ensure!(data.len() <= 8192, "payload too long (max 8192 bytes)");
for chunk in data.chunks(BLE_CHAR_CHUNK_SIZE) { for chunk in data.chunks(BLE_CHAR_CHUNK_SIZE) {

View file

@ -1,24 +1,29 @@
#![warn(clippy::all, clippy::pedantic)] #![warn(clippy::all, clippy::pedantic)]
use std::{fs, path::PathBuf};
use anyhow::{Context, Result}; use anyhow::{Context, Result};
use badgemagic::{ use badgemagic::{
ble::Device as BleDevice, ble::Device as BleDevice,
protocol::{Mode, PayloadBuffer, Speed, Style}, protocol::{Brightness, Mode, PayloadBuffer, Speed, Style},
usb_hid::Device as UsbDevice, usb_hid::Device as UsbDevice,
}; };
use base64::Engine; use base64::Engine;
use clap::{Parser, ValueEnum}; use clap::{Parser, ValueEnum};
#[cfg(not(any(feature = "u8g2-fonts")))]
use embedded_graphics::mono_font::{iso_8859_1::FONT_6X9, MonoTextStyle};
use embedded_graphics::{ use embedded_graphics::{
geometry::Point, geometry::Point,
image::{Image, ImageRawLE}, image::{Image, ImageRawLE},
mono_font::{iso_8859_1::FONT_6X9, MonoTextStyle},
pixelcolor::BinaryColor, pixelcolor::BinaryColor,
text::Text, text::Text,
Drawable, Pixel, Drawable, Pixel,
}; };
use image::{
codecs::gif::GifDecoder, imageops::FilterType, AnimationDecoder, ImageReader, Pixel as iPixel,
};
use serde::Deserialize; use serde::Deserialize;
use std::{fs, fs::File, io::BufReader, path::PathBuf};
#[cfg(feature = "u8g2-fonts")]
use u8g2_fonts::{fonts::u8g2_font_lucasfont_alternate_tf, U8g2TextStyle};
#[derive(Parser)] #[derive(Parser)]
/// Upload a configuration with up to 8 messages to an LED badge /// Upload a configuration with up to 8 messages to an LED badge
@ -43,6 +48,10 @@ struct Args {
#[clap(long)] #[clap(long)]
transport: TransportProtocol, transport: TransportProtocol,
/// Brightness of the panel
#[clap(long)]
brightness: Option<Brightness>,
/// List all devices visible to a transport and exit /// List all devices visible to a transport and exit
#[clap(long)] #[clap(long)]
list_devices: bool, list_devices: bool,
@ -91,8 +100,8 @@ enum Content {
Bitstring { bitstring: String }, Bitstring { bitstring: String },
BitmapBase64 { width: u32, bitmap_base64: String }, BitmapBase64 { width: u32, bitmap_base64: String },
BitmapFile { width: u32, bitmap_file: PathBuf }, BitmapFile { width: u32, bitmap_file: PathBuf },
// TODO: implement png ImageFile { img_file: PathBuf },
// PngFile { png_file: PathBuf }, GifFile { gif_file: PathBuf },
} }
fn main() -> Result<()> { fn main() -> Result<()> {
@ -102,7 +111,7 @@ fn main() -> Result<()> {
return list_devices(&args.transport); return list_devices(&args.transport);
} }
let payload = gnerate_payload(&mut args)?; let payload = generate_payload(&mut args)?;
write_payload(&args.transport, payload) write_payload(&args.transport, payload)
} }
@ -128,7 +137,7 @@ fn list_devices(transport: &TransportProtocol) -> Result<()> {
Ok(()) Ok(())
} }
fn gnerate_payload(args: &mut Args) -> Result<PayloadBuffer> { fn generate_payload(args: &mut Args) -> Result<PayloadBuffer> {
let config_path = args.config.take().unwrap_or_default(); let config_path = args.config.take().unwrap_or_default();
let config = fs::read_to_string(&config_path) let config = fs::read_to_string(&config_path)
.with_context(|| format!("load config: {config_path:?}"))?; .with_context(|| format!("load config: {config_path:?}"))?;
@ -146,7 +155,7 @@ fn gnerate_payload(args: &mut Args) -> Result<PayloadBuffer> {
} }
}; };
let mut payload = PayloadBuffer::new(); let mut payload = PayloadBuffer::new(args.brightness.unwrap_or_default());
for message in config.messages { for message in config.messages {
let mut style = Style::default(); let mut style = Style::default();
@ -157,13 +166,23 @@ fn gnerate_payload(args: &mut Args) -> Result<PayloadBuffer> {
style = style.border(); style = style.border();
} }
style = style.speed(message.speed).mode(message.mode); style = style.speed(message.speed).mode(message.mode);
match message.content { match message.content {
Content::Text { text } => { Content::Text { text } => {
#[cfg(not(any(feature = "u8g2-fonts")))]
let text = Text::new( let text = Text::new(
&text, &text,
Point::new(0, 7), Point::new(0, 7),
MonoTextStyle::new(&FONT_6X9, BinaryColor::On), MonoTextStyle::new(&FONT_6X9, BinaryColor::On),
); );
#[cfg(feature = "u8g2-fonts")]
let text = Text::new(
&text,
Point::new(0, 8),
U8g2TextStyle::new(u8g2_font_lucasfont_alternate_tf, BinaryColor::On),
);
payload.add_message_drawable(style, &text); payload.add_message_drawable(style, &text);
} }
Content::Bitstring { bitstring } => { Content::Bitstring { bitstring } => {
@ -190,12 +209,8 @@ fn gnerate_payload(args: &mut Args) -> Result<PayloadBuffer> {
// off // off
} }
'X' => { 'X' => {
Pixel( Pixel(Point::new(x.try_into()?, y.try_into()?), BinaryColor::On)
Point::new(x.try_into().unwrap(), y.try_into().unwrap()), .draw(&mut buffer)?;
BinaryColor::On,
)
.draw(&mut buffer)
.unwrap();
} }
_ => anyhow::bail!("invalid bit value for bit ({x}, {y}): {c:?}"), _ => anyhow::bail!("invalid bit value for bit ({x}, {y}): {c:?}"),
} }
@ -223,6 +238,53 @@ fn gnerate_payload(args: &mut Args) -> Result<PayloadBuffer> {
let image = Image::new(&image_raw, Point::zero()); let image = Image::new(&image_raw, Point::zero());
payload.add_message_drawable(style, &image); payload.add_message_drawable(style, &image);
} }
Content::ImageFile { img_file } => {
let img_reader = ImageReader::open(img_file)?;
let img = img_reader
.decode()?
.resize(u32::MAX, 11, FilterType::Nearest)
.into_luma8();
let (width, height) = img.dimensions();
let mut buffer = payload.add_message(style, (width as usize + 7) / 8);
for y in 0..height {
for x in 0..width {
if img.get_pixel(x, y).0 > [31] {
Pixel(Point::new(x.try_into()?, y.try_into()?), BinaryColor::On)
.draw(&mut buffer)?;
}
}
}
}
Content::GifFile { gif_file } => {
let file_in = BufReader::new(File::open(gif_file)?);
let frames = GifDecoder::new(file_in)?
.into_frames()
.collect_frames()
.expect("error decoding gif");
let frame_count = frames.len();
let (width, height) = frames.first().unwrap().buffer().dimensions();
if height != 11 || width != 44 {
anyhow::bail!("Expected 44x11 pixel gif file");
}
let mut buffer = payload.add_message(style, (48 * frame_count + 7) / 8);
for (i, frame) in frames.iter().enumerate() {
let buf = frame.buffer();
for y in 0..11 {
for x in 0..44 {
if buf.get_pixel(x, y).to_luma().0 > [31] {
Pixel(
Point::new((x as usize + i * 48).try_into()?, y.try_into()?),
BinaryColor::On,
)
.draw(&mut buffer)?;
}
}
}
}
}
} }
} }

View file

@ -1,7 +1,5 @@
//! Protocol used to update the badge //! Protocol used to update the badge
use std::num::TryFromIntError;
#[cfg(feature = "embedded-graphics")] #[cfg(feature = "embedded-graphics")]
use embedded_graphics::{ use embedded_graphics::{
draw_target::DrawTarget, draw_target::DrawTarget,
@ -11,6 +9,7 @@ use embedded_graphics::{
primitives::Rectangle, primitives::Rectangle,
Drawable, Drawable,
}; };
use std::num::TryFromIntError;
use time::OffsetDateTime; use time::OffsetDateTime;
use zerocopy::{BigEndian, FromBytes, Immutable, IntoBytes, KnownLayout, U16}; use zerocopy::{BigEndian, FromBytes, Immutable, IntoBytes, KnownLayout, U16};
@ -54,7 +53,7 @@ impl Style {
self self
} }
/// Show a dotted border arround the display. /// Show a dotted border around the display.
/// ``` /// ```
/// use badgemagic::protocol::Style; /// use badgemagic::protocol::Style;
/// # ( /// # (
@ -161,7 +160,7 @@ impl TryFrom<u8> for Speed {
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] #[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
#[cfg_attr(feature = "serde", serde(rename_all = "snake_case"))] #[cfg_attr(feature = "serde", serde(rename_all = "snake_case"))]
pub enum Mode { pub enum Mode {
/// Scroll thorugh the message from left to right /// Scroll through the message from left to right
#[default] #[default]
Left, Left,
@ -193,14 +192,39 @@ pub enum Mode {
Laser, Laser,
} }
/// Display Brightness
#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, Hash)]
#[cfg_attr(feature = "cli", derive(clap::ValueEnum))]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
#[cfg_attr(feature = "serde", serde(rename_all = "PascalCase"))]
pub enum Brightness {
#[default]
Full,
ThreeQuarters,
Half,
OneQuarter,
}
impl From<Brightness> for u8 {
fn from(value: Brightness) -> Self {
match value {
Brightness::Full => 0x00,
Brightness::ThreeQuarters => 0x10,
Brightness::Half => 0x20,
Brightness::OneQuarter => 0x30,
}
}
}
const MSG_PADDING_ALIGN: usize = 64; const MSG_PADDING_ALIGN: usize = 64;
const MAGIC: [u8; 6] = *b"wang\0\0"; const MAGIC: [u8; 5] = *b"wang\0";
#[derive(FromBytes, IntoBytes, Immutable, KnownLayout)] #[derive(FromBytes, IntoBytes, Immutable, KnownLayout)]
#[repr(C)] #[repr(C)]
struct Header { struct Header {
magic: [u8; 6], magic: [u8; 5],
brightness: u8,
blink: u8, blink: u8,
border: u8, border: u8,
speed_and_mode: [u8; 8], speed_and_mode: [u8; 8],
@ -241,7 +265,7 @@ impl Timestamp {
/// Buffer to create a payload /// Buffer to create a payload
/// ///
/// A payload consits of up to 8 messages /// A payload consists of up to 8 messages
/// ``` /// ```
/// # #[cfg(feature = "embedded-graphics")] /// # #[cfg(feature = "embedded-graphics")]
/// # fn main() { /// # fn main() {
@ -252,7 +276,7 @@ impl Timestamp {
/// primitives::{PrimitiveStyle, Rectangle, Styled}, /// primitives::{PrimitiveStyle, Rectangle, Styled},
/// }; /// };
/// ///
/// let mut buffer = PayloadBuffer::new(); /// let mut buffer = PayloadBuffer::default();
/// buffer.add_message_drawable( /// buffer.add_message_drawable(
/// Style::default(), /// Style::default(),
/// &Styled::new( /// &Styled::new(
@ -271,18 +295,19 @@ pub struct PayloadBuffer {
impl Default for PayloadBuffer { impl Default for PayloadBuffer {
fn default() -> Self { fn default() -> Self {
Self::new() Self::new(Brightness::Full)
} }
} }
impl PayloadBuffer { impl PayloadBuffer {
/// Create a new empty buffer /// Create a new empty buffer
#[must_use] #[must_use]
pub fn new() -> Self { pub fn new(brightness: Brightness) -> Self {
Self { Self {
num_messages: 0, num_messages: 0,
data: Header { data: Header {
magic: MAGIC, magic: MAGIC,
brightness: brightness.into(),
blink: 0, blink: 0,
border: 0, border: 0,
speed_and_mode: [0; 8], speed_and_mode: [0; 8],
@ -368,7 +393,7 @@ impl PayloadBuffer {
&self.data &self.data
} }
/// Convert the payload buffe into bytes (with padding) /// Convert the payload buffer into bytes (with padding)
#[allow(clippy::missing_panics_doc)] // should never panic #[allow(clippy::missing_panics_doc)] // should never panic
#[must_use] #[must_use]
pub fn into_padded_bytes(self) -> impl AsRef<[u8]> { pub fn into_padded_bytes(self) -> impl AsRef<[u8]> {
@ -484,10 +509,9 @@ impl DrawTarget for MessageBuffer<'_> {
#[cfg(test)] #[cfg(test)]
mod test { mod test {
use super::{Brightness, Speed};
use std::ops::Range; use std::ops::Range;
use super::Speed;
#[test] #[test]
fn speed_to_u8_and_back() { fn speed_to_u8_and_back() {
const VALID_SPEED_VALUES: Range<u8> = 1..8; const VALID_SPEED_VALUES: Range<u8> = 1..8;
@ -499,4 +523,18 @@ mod test {
} }
} }
} }
#[test]
fn brightness_to_u8() {
const VALID_BRIGHTNESS_VALUES: [(Brightness, u8); 4] = [
(Brightness::Full, 0x00),
(Brightness::ThreeQuarters, 0x10),
(Brightness::Half, 0x20),
(Brightness::OneQuarter, 0x30),
];
for i in VALID_BRIGHTNESS_VALUES {
assert_eq!(u8::from(Brightness::from(i.0)), i.1);
}
}
} }

View file

@ -29,7 +29,7 @@ pub struct Device {
} }
impl Device { impl Device {
/// Return a list of all usb devies as a string representation /// Return a list of all usb devices as a string representation
pub fn list_all() -> Result<Vec<String>> { pub fn list_all() -> Result<Vec<String>> {
let api = HidApi::new().context("create hid api")?; let api = HidApi::new().context("create hid api")?;
let devices = api.device_list(); let devices = api.device_list();
@ -92,7 +92,7 @@ impl Device {
fn write_raw(device: &HidDevice, data: &[u8]) -> Result<()> { fn write_raw(device: &HidDevice, data: &[u8]) -> Result<()> {
anyhow::ensure!(data.len() % 64 == 0, "payload not padded to 64 bytes"); anyhow::ensure!(data.len() % 64 == 0, "payload not padded to 64 bytes");
// the device will brick itself if the payload is too long (more then 8192 bytes) // the device will brick itself if the payload is too long (more than 8192 bytes)
anyhow::ensure!(data.len() <= 8192, "payload too long (max 8192 bytes)"); anyhow::ensure!(data.len() <= 8192, "payload too long (max 8192 bytes)");
// just to be sure // just to be sure