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]
name = "badgemagic"
version = "0.1.0"
authors = ["Martin Michaelis <code@mgjm.de>"]
version = "0.2.0"
authors = ["Martin Michaelis <code@mgjm.de>", "Valentin Weber <weva+code@kabelsalat.ch>"]
edition = "2021"
description = "Badge Magic with LEDs - Library and CLI"
homepage = "https://badgemagic.fossasia.org"
@ -35,18 +35,21 @@ embedded-graphics = ["dep:embedded-graphics"]
serde = ["dep:serde"]
usb-hid = ["dep:hidapi"]
ble = ["dep:btleplug", "dep:uuid", "dep:tokio"]
u8g2-fonts = ["dep:u8g2-fonts"]
[dependencies]
anyhow = "1.0.95"
anyhow = "1.0.98"
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 }
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 }
btleplug = { version = "0.11.8", optional = true }
uuid = { version = "1.17.0", optional = true }
tokio = { version = "1.46.1", features = ["rt"], optional = true }
serde = { version = "1.0.219", features = ["derive"], optional = true }
serde_json = { version = "1.0.140", optional = true }
time = "0.3.41"
toml = { version = "0.9.0", optional = true }
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 2025 Valentin Weber
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the “Software”), to deal

View file

@ -28,7 +28,7 @@ chmod +x badgemagic
./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:

View file

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

View file

@ -191,7 +191,7 @@ impl Device {
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)");
for chunk in data.chunks(BLE_CHAR_CHUNK_SIZE) {

View file

@ -1,24 +1,29 @@
#![warn(clippy::all, clippy::pedantic)]
use std::{fs, path::PathBuf};
use anyhow::{Context, Result};
use badgemagic::{
ble::Device as BleDevice,
protocol::{Mode, PayloadBuffer, Speed, Style},
protocol::{Brightness, Mode, PayloadBuffer, Speed, Style},
usb_hid::Device as UsbDevice,
};
use base64::Engine;
use clap::{Parser, ValueEnum};
#[cfg(not(any(feature = "u8g2-fonts")))]
use embedded_graphics::mono_font::{iso_8859_1::FONT_6X9, MonoTextStyle};
use embedded_graphics::{
geometry::Point,
image::{Image, ImageRawLE},
mono_font::{iso_8859_1::FONT_6X9, MonoTextStyle},
pixelcolor::BinaryColor,
text::Text,
Drawable, Pixel,
};
use image::{
codecs::gif::GifDecoder, imageops::FilterType, AnimationDecoder, ImageReader, Pixel as iPixel,
};
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)]
/// Upload a configuration with up to 8 messages to an LED badge
@ -43,6 +48,10 @@ struct Args {
#[clap(long)]
transport: TransportProtocol,
/// Brightness of the panel
#[clap(long)]
brightness: Option<Brightness>,
/// List all devices visible to a transport and exit
#[clap(long)]
list_devices: bool,
@ -91,8 +100,8 @@ enum Content {
Bitstring { bitstring: String },
BitmapBase64 { width: u32, bitmap_base64: String },
BitmapFile { width: u32, bitmap_file: PathBuf },
// TODO: implement png
// PngFile { png_file: PathBuf },
ImageFile { img_file: PathBuf },
GifFile { gif_file: PathBuf },
}
fn main() -> Result<()> {
@ -102,7 +111,7 @@ fn main() -> Result<()> {
return list_devices(&args.transport);
}
let payload = gnerate_payload(&mut args)?;
let payload = generate_payload(&mut args)?;
write_payload(&args.transport, payload)
}
@ -128,7 +137,7 @@ fn list_devices(transport: &TransportProtocol) -> Result<()> {
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 = fs::read_to_string(&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 {
let mut style = Style::default();
@ -157,13 +166,23 @@ fn gnerate_payload(args: &mut Args) -> Result<PayloadBuffer> {
style = style.border();
}
style = style.speed(message.speed).mode(message.mode);
match message.content {
Content::Text { text } => {
#[cfg(not(any(feature = "u8g2-fonts")))]
let text = Text::new(
&text,
Point::new(0, 7),
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);
}
Content::Bitstring { bitstring } => {
@ -190,12 +209,8 @@ fn gnerate_payload(args: &mut Args) -> Result<PayloadBuffer> {
// off
}
'X' => {
Pixel(
Point::new(x.try_into().unwrap(), y.try_into().unwrap()),
BinaryColor::On,
)
.draw(&mut buffer)
.unwrap();
Pixel(Point::new(x.try_into()?, y.try_into()?), BinaryColor::On)
.draw(&mut buffer)?;
}
_ => 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());
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
use std::num::TryFromIntError;
#[cfg(feature = "embedded-graphics")]
use embedded_graphics::{
draw_target::DrawTarget,
@ -11,6 +9,7 @@ use embedded_graphics::{
primitives::Rectangle,
Drawable,
};
use std::num::TryFromIntError;
use time::OffsetDateTime;
use zerocopy::{BigEndian, FromBytes, Immutable, IntoBytes, KnownLayout, U16};
@ -54,7 +53,7 @@ impl Style {
self
}
/// Show a dotted border arround the display.
/// Show a dotted border around the display.
/// ```
/// 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", serde(rename_all = "snake_case"))]
pub enum Mode {
/// Scroll thorugh the message from left to right
/// Scroll through the message from left to right
#[default]
Left,
@ -193,14 +192,39 @@ pub enum Mode {
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 MAGIC: [u8; 6] = *b"wang\0\0";
const MAGIC: [u8; 5] = *b"wang\0";
#[derive(FromBytes, IntoBytes, Immutable, KnownLayout)]
#[repr(C)]
struct Header {
magic: [u8; 6],
magic: [u8; 5],
brightness: u8,
blink: u8,
border: u8,
speed_and_mode: [u8; 8],
@ -241,7 +265,7 @@ impl Timestamp {
/// 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")]
/// # fn main() {
@ -252,7 +276,7 @@ impl Timestamp {
/// primitives::{PrimitiveStyle, Rectangle, Styled},
/// };
///
/// let mut buffer = PayloadBuffer::new();
/// let mut buffer = PayloadBuffer::default();
/// buffer.add_message_drawable(
/// Style::default(),
/// &Styled::new(
@ -271,18 +295,19 @@ pub struct PayloadBuffer {
impl Default for PayloadBuffer {
fn default() -> Self {
Self::new()
Self::new(Brightness::Full)
}
}
impl PayloadBuffer {
/// Create a new empty buffer
#[must_use]
pub fn new() -> Self {
pub fn new(brightness: Brightness) -> Self {
Self {
num_messages: 0,
data: Header {
magic: MAGIC,
brightness: brightness.into(),
blink: 0,
border: 0,
speed_and_mode: [0; 8],
@ -368,7 +393,7 @@ impl PayloadBuffer {
&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
#[must_use]
pub fn into_padded_bytes(self) -> impl AsRef<[u8]> {
@ -484,10 +509,9 @@ impl DrawTarget for MessageBuffer<'_> {
#[cfg(test)]
mod test {
use super::{Brightness, Speed};
use std::ops::Range;
use super::Speed;
#[test]
fn speed_to_u8_and_back() {
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 {
/// 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>> {
let api = HidApi::new().context("create hid api")?;
let devices = api.device_list();
@ -92,7 +92,7 @@ impl Device {
fn write_raw(device: &HidDevice, data: &[u8]) -> Result<()> {
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)");
// just to be sure