From a9b38fb4275f661bcff736002a8dca7c31cedf4e Mon Sep 17 00:00:00 2001 From: Valentin Weber Date: Wed, 16 Jul 2025 19:56:15 +0200 Subject: [PATCH 1/9] feat: add support for brightness control --- src/main.rs | 5 +++- src/protocol.rs | 72 +++++++++++++++++++++++++++++++++++++++++-------- 2 files changed, 65 insertions(+), 12 deletions(-) diff --git a/src/main.rs b/src/main.rs index 116a66c..7efb3e0 100644 --- a/src/main.rs +++ b/src/main.rs @@ -6,7 +6,7 @@ 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; @@ -63,6 +63,8 @@ enum TransportProtocol { #[derive(Deserialize)] #[serde(deny_unknown_fields)] struct Config { + #[serde(default)] + brightness: Option, #[serde(rename = "message")] messages: Vec, } @@ -148,6 +150,7 @@ fn gnerate_payload(args: &mut Args) -> Result { }; let mut payload = PayloadBuffer::new(); + payload.set_brightness(config.brightness.unwrap_or_default()); for message in config.messages { let mut style = Style::default(); diff --git a/src/protocol.rs b/src/protocol.rs index e425b6c..ce14bc2 100644 --- a/src/protocol.rs +++ b/src/protocol.rs @@ -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 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,47 @@ pub enum Mode { Laser, } +/// Display Brightness +#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, Hash)] +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +#[cfg_attr(feature = "serde", serde(rename_all = "snake_case"))] +pub enum Brightness { + #[default] + Full = 0x00, + ThreeQuarters = 0x10, + Half = 0x20, + OneQuarter = 0x30, +} + +impl From for u8 { + fn from(value: Brightness) -> Self { + value as u8 + } +} + +impl TryFrom for Brightness { + type Error = TryFromIntError; + + fn try_from(value: u8) -> Result { + Ok(match value { + 0x00 => Self::Full, + 0x10 => Self::ThreeQuarters, + 0x20 => Self::Half, + 0x30 => Self::OneQuarter, + _ => return Err(u8::try_from(-1).unwrap_err()), + }) + } +} + 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 +273,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 +284,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( @@ -283,6 +315,7 @@ impl PayloadBuffer { num_messages: 0, data: Header { magic: MAGIC, + brightness: 0, blink: 0, border: 0, speed_and_mode: [0; 8], @@ -300,6 +333,10 @@ impl PayloadBuffer { Header::mut_from_prefix(&mut self.data).unwrap().0 } + pub fn set_brightness(&mut self, brightness: Brightness) { + self.header_mut().brightness = brightness.into(); + } + /// Return the current number of messages pub fn num_messages(&mut self) -> usize { self.num_messages as usize @@ -368,7 +405,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 +521,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 = 1..8; @@ -499,4 +535,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 (value, raw) in VALID_BRIGHTNESS_VALUES { + assert_eq!(u8::from(value), raw); + } + } } From 5d63cc8e485dce29e4d231f5b672bf9fd1d17d34 Mon Sep 17 00:00:00 2001 From: Valentin Weber Date: Wed, 16 Jul 2025 20:03:50 +0200 Subject: [PATCH 2/9] chore: group use statements by category --- src/protocol.rs | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/src/protocol.rs b/src/protocol.rs index ce14bc2..0a486d8 100644 --- a/src/protocol.rs +++ b/src/protocol.rs @@ -1,5 +1,7 @@ //! Protocol used to update the badge +use std::num::TryFromIntError; + #[cfg(feature = "embedded-graphics")] use embedded_graphics::{ draw_target::DrawTarget, @@ -9,7 +11,6 @@ use embedded_graphics::{ primitives::Rectangle, Drawable, }; -use std::num::TryFromIntError; use time::OffsetDateTime; use zerocopy::{BigEndian, FromBytes, Immutable, IntoBytes, KnownLayout, U16}; @@ -521,9 +522,10 @@ impl DrawTarget for MessageBuffer<'_> { #[cfg(test)] mod test { - use super::{Brightness, Speed}; use std::ops::Range; + use super::{Brightness, Speed}; + #[test] fn speed_to_u8_and_back() { const VALID_SPEED_VALUES: Range = 1..8; From 8047e719f85e1e9f509ed3ebdb8dee65352ffd07 Mon Sep 17 00:00:00 2001 From: Valentin Weber Date: Wed, 16 Jul 2025 21:37:00 +0200 Subject: [PATCH 3/9] test: add u8 to Brightness test --- src/protocol.rs | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/protocol.rs b/src/protocol.rs index 0a486d8..660c636 100644 --- a/src/protocol.rs +++ b/src/protocol.rs @@ -539,7 +539,7 @@ mod test { } #[test] - fn brightness_to_u8() { + fn brightness_to_u8_and_back() { const VALID_BRIGHTNESS_VALUES: [(Brightness, u8); 4] = [ (Brightness::Full, 0x00), (Brightness::ThreeQuarters, 0x10), @@ -549,6 +549,8 @@ mod test { for (value, raw) in VALID_BRIGHTNESS_VALUES { assert_eq!(u8::from(value), raw); + assert_eq!(Brightness::try_from(raw).unwrap(), value); } } + } From ce1faf82351186ea1abecb608f1849b072e824db Mon Sep 17 00:00:00 2001 From: Valentin Weber Date: Wed, 16 Jul 2025 22:05:09 +0200 Subject: [PATCH 4/9] fix: remove obsolete Option wrap --- src/main.rs | 5 +- src/protocol.rs | 556 ------------------------------------------------ 2 files changed, 3 insertions(+), 558 deletions(-) delete mode 100644 src/protocol.rs diff --git a/src/main.rs b/src/main.rs index 7efb3e0..b5d334a 100644 --- a/src/main.rs +++ b/src/main.rs @@ -64,7 +64,8 @@ enum TransportProtocol { #[serde(deny_unknown_fields)] struct Config { #[serde(default)] - brightness: Option, + brightness: Brightness, + #[serde(rename = "message")] messages: Vec, } @@ -150,7 +151,7 @@ fn gnerate_payload(args: &mut Args) -> Result { }; let mut payload = PayloadBuffer::new(); - payload.set_brightness(config.brightness.unwrap_or_default()); + payload.set_brightness(config.brightness); for message in config.messages { let mut style = Style::default(); diff --git a/src/protocol.rs b/src/protocol.rs deleted file mode 100644 index 660c636..0000000 --- a/src/protocol.rs +++ /dev/null @@ -1,556 +0,0 @@ -//! Protocol used to update the badge - -use std::num::TryFromIntError; - -#[cfg(feature = "embedded-graphics")] -use embedded_graphics::{ - draw_target::DrawTarget, - geometry::{Dimensions, Point, Size}, - pixelcolor::BinaryColor, - prelude::Pixel, - primitives::Rectangle, - Drawable, -}; -use time::OffsetDateTime; -use zerocopy::{BigEndian, FromBytes, Immutable, IntoBytes, KnownLayout, U16}; - -/// Message style configuration -/// ``` -/// use badgemagic::protocol::{Mode, Style}; -/// # ( -/// Style::default().blink().border().mode(Mode::Center) -/// # ); -/// ``` -#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, Hash)] -#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] -#[cfg_attr(feature = "serde", serde(deny_unknown_fields))] -#[must_use] -pub struct Style { - #[cfg_attr(feature = "serde", serde(default))] - blink: bool, - - #[cfg_attr(feature = "serde", serde(default))] - border: bool, - - #[cfg_attr(feature = "serde", serde(default))] - speed: Speed, - - #[cfg_attr(feature = "serde", serde(default))] - mode: Mode, -} - -impl Style { - /// Enable blink mode - /// - /// The message will blink. - /// ``` - /// use badgemagic::protocol::Style; - /// # ( - /// Style::default().blink() - /// # ); - /// ``` - pub fn blink(mut self) -> Self { - self.blink = true; - self - } - - /// Show a dotted border around the display. - /// ``` - /// use badgemagic::protocol::Style; - /// # ( - /// Style::default().blink() - /// # ); - /// ``` - pub fn border(mut self) -> Self { - self.border = true; - self - } - - /// Set the update speed of the animations. - /// - /// The animation will jump to the next pixel at the specified frame rate. - /// ``` - /// use badgemagic::protocol::{Speed, Style}; - /// # ( - /// Style::default().speed(Speed::Fps1_2) - /// # ); - /// ``` - pub fn speed(mut self, speed: Speed) -> Self { - self.speed = speed; - self - } - - /// Set the display animation. - /// ``` - /// use badgemagic::protocol::{Mode, Style}; - /// # ( - /// Style::default().mode(Mode::Curtain) - /// # ); - /// ``` - /// - /// Show text centered, without an animation: - /// ``` - /// use badgemagic::protocol::{Mode, Style}; - /// # ( - /// Style::default().mode(Mode::Center) - /// # ); - /// ``` - pub fn mode(mut self, mode: Mode) -> Self { - self.mode = mode; - self - } -} - -/// Animation update speed -#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, Hash)] -#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] -#[cfg_attr(feature = "serde", serde(try_from = "u8", into = "u8"))] -pub enum Speed { - /// 1.2 FPS - Fps1_2, - - /// 1.3 FPS - Fps1_3, - - /// 2 FPS - Fps2, - - /// 2.4 FPS - Fps2_4, - - /// 2.8 FPS - #[default] - Fps2_8, - - /// 4.5 FPS - Fps4_5, - - /// 7.5 FPS - Fps7_5, - - /// 15 FPS - Fps15, -} - -impl From for u8 { - fn from(value: Speed) -> Self { - value as u8 - } -} - -impl TryFrom for Speed { - type Error = TryFromIntError; - - fn try_from(value: u8) -> Result { - Ok(match value { - 0 => Self::Fps1_2, - 1 => Self::Fps1_3, - 2 => Self::Fps2, - 3 => Self::Fps2_4, - 4 => Self::Fps2_8, - 5 => Self::Fps4_5, - 6 => Self::Fps7_5, - 7 => Self::Fps15, - _ => return Err(u8::try_from(-1).unwrap_err()), - }) - } -} - -/// Message display mode -#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, Hash)] -#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] -#[cfg_attr(feature = "serde", serde(rename_all = "snake_case"))] -pub enum Mode { - /// Scroll through the message from left to right - #[default] - Left, - - /// Scroll through the message from right to left - Right, - - /// Enter from the bottom, move up - Up, - - /// Enter from the top, move down - Down, - - /// Center the text, no animation - Center, - - /// Fast mode for animations - /// - /// Will leave a 4 pixel gap between screens: - /// Place a 44x11 pixel screen every 48 pixels - Fast, - - /// Drop rows of pixels from the top - Drop, - - /// Open a curtain and reveal the message - Curtain, - - /// A laser will reveal the message from left to right - Laser, -} - -/// Display Brightness -#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, Hash)] -#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] -#[cfg_attr(feature = "serde", serde(rename_all = "snake_case"))] -pub enum Brightness { - #[default] - Full = 0x00, - ThreeQuarters = 0x10, - Half = 0x20, - OneQuarter = 0x30, -} - -impl From for u8 { - fn from(value: Brightness) -> Self { - value as u8 - } -} - -impl TryFrom for Brightness { - type Error = TryFromIntError; - - fn try_from(value: u8) -> Result { - Ok(match value { - 0x00 => Self::Full, - 0x10 => Self::ThreeQuarters, - 0x20 => Self::Half, - 0x30 => Self::OneQuarter, - _ => return Err(u8::try_from(-1).unwrap_err()), - }) - } -} - -const MSG_PADDING_ALIGN: usize = 64; - -const MAGIC: [u8; 5] = *b"wang\0"; - -#[derive(FromBytes, IntoBytes, Immutable, KnownLayout)] -#[repr(C)] -struct Header { - magic: [u8; 5], - brightness: u8, - blink: u8, - border: u8, - speed_and_mode: [u8; 8], - message_length: [U16; 8], - _padding_1: [u8; 6], - timestamp: Timestamp, - _padding_2: [u8; 20], -} - -#[derive(FromBytes, IntoBytes, Immutable, KnownLayout)] -#[repr(C)] -struct Timestamp { - year: u8, - month: u8, - day: u8, - hour: u8, - minute: u8, - second: u8, -} - -impl Timestamp { - fn new(ts: OffsetDateTime) -> Self { - Self { - #[allow(clippy::cast_possible_truncation)] // clippy does not understand `rem_euclid(100) <= 100` - year: ts.year().rem_euclid(100) as u8, - month: ts.month() as u8, - day: ts.day(), - hour: ts.hour(), - minute: ts.minute(), - second: ts.second(), - } - } - - fn now() -> Self { - Self::new(OffsetDateTime::now_utc()) - } -} - -/// Buffer to create a payload -/// -/// A payload consists of up to 8 messages -/// ``` -/// # #[cfg(feature = "embedded-graphics")] -/// # fn main() { -/// # use badgemagic::protocol::{PayloadBuffer, Style}; -/// use badgemagic::embedded_graphics::{ -/// geometry::{Point, Size}, -/// pixelcolor::BinaryColor, -/// primitives::{PrimitiveStyle, Rectangle, Styled}, -/// }; -/// -/// let mut buffer = PayloadBuffer::default(); -/// buffer.add_message_drawable( -/// Style::default(), -/// &Styled::new( -/// Rectangle::new(Point::new(2, 2), Size::new(4, 7)), -/// PrimitiveStyle::with_fill(BinaryColor::On), -/// ), -/// ); -/// # } -/// # #[cfg(not(feature = "embedded-graphics"))] -/// # fn main() {} -/// ``` -pub struct PayloadBuffer { - num_messages: u8, - data: Vec, -} - -impl Default for PayloadBuffer { - fn default() -> Self { - Self::new() - } -} - -impl PayloadBuffer { - /// Create a new empty buffer - #[must_use] - pub fn new() -> Self { - Self { - num_messages: 0, - data: Header { - magic: MAGIC, - brightness: 0, - blink: 0, - border: 0, - speed_and_mode: [0; 8], - message_length: [0.into(); 8], - _padding_1: [0; 6], - timestamp: Timestamp::now(), - _padding_2: [0; 20], - } - .as_bytes() - .into(), - } - } - - fn header_mut(&mut self) -> &mut Header { - Header::mut_from_prefix(&mut self.data).unwrap().0 - } - - pub fn set_brightness(&mut self, brightness: Brightness) { - self.header_mut().brightness = brightness.into(); - } - - /// Return the current number of messages - pub fn num_messages(&mut self) -> usize { - self.num_messages as usize - } - - /// Add a messages containing the specified `content` - /// - /// ## Panics - /// This method panics if it is unable to draw the content. - #[cfg(feature = "embedded-graphics")] - pub fn add_message_drawable( - &mut self, - style: Style, - content: &(impl Drawable + Dimensions), - ) -> O { - #[allow(clippy::cast_possible_wrap)] - fn saturating_usize_to_isize(n: usize) -> isize { - usize::min(n, isize::MAX as usize) as isize - } - - fn add(a: i32, b: u32) -> usize { - let result = a as isize + saturating_usize_to_isize(b as usize); - result.try_into().unwrap_or_default() - } - - let bounds = content.bounding_box(); - let width = add(bounds.top_left.x, bounds.size.width); - let mut message = self.add_message(style, width.div_ceil(8)); - content.draw(&mut message).unwrap() - } - - /// Add a message with `count * 8` columns - /// - /// The returned `MessageBuffer` can be used as an `embedded_graphics::DrawTarget` - /// with the `embedded_graphics` feature. - /// - /// ## Panics - /// Panics if the supported number of messages is reached. - pub fn add_message(&mut self, style: Style, count: usize) -> MessageBuffer { - let index = self.num_messages as usize; - assert!( - index < 8, - "maximum number of supported messages reached: {index} messages", - ); - self.num_messages += 1; - - let header = self.header_mut(); - - if style.blink { - header.blink |= 1 << index; - } - if style.border { - header.border |= 1 << index; - } - header.speed_and_mode[index] = ((style.speed as u8) << 4) | style.mode as u8; - header.message_length[index] = count.try_into().unwrap(); - - let start = self.data.len(); - self.data.resize(start + count * 11, 0); - MessageBuffer(FromBytes::mut_from_bytes(&mut self.data[start..]).unwrap()) - } - - /// Get the current payload as bytes (without padding) - #[must_use] - pub fn as_bytes(&self) -> &[u8] { - &self.data - } - - /// 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]> { - let mut data = self.data; - - let prev_len = data.len(); - - // pad msg to align to 64 bytes - data.resize( - (data.len() + (MSG_PADDING_ALIGN - 1)) & !(MSG_PADDING_ALIGN - 1), - 0, - ); - - // validate alignment - assert_eq!(data.len() % 64, 0); - assert!(prev_len <= data.len()); - - data - } -} - -/// A display buffer for a single message. -/// -/// Can be used as an `embedded_graphics::DrawTarget`. -pub struct MessageBuffer<'a>(&'a mut [[u8; 11]]); - -impl MessageBuffer<'_> { - /// Set the state of the pixel at point (`x`, `y`) - /// - /// Returns `None` if the pixel was out of bounds. - pub fn set(&mut self, (x, y): (usize, usize), state: State) -> Option<()> { - let byte = self.0.get_mut(x / 8)?.get_mut(y)?; - let bit = 0x80 >> (x % 8); - match state { - State::Off => { - *byte &= !bit; - } - State::On => { - *byte |= bit; - } - } - Some(()) - } - - #[cfg(feature = "embedded-graphics")] - fn set_embedded_graphics(&mut self, point: Point, color: BinaryColor) -> Option<()> { - let x = point.x.try_into().ok()?; - let y = point.y.try_into().ok()?; - self.set((x, y), color.into()) - } -} - -/// State of a pixel -#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] -pub enum State { - #[default] - Off, - On, -} - -impl From for State { - fn from(value: bool) -> Self { - if value { - Self::On - } else { - Self::Off - } - } -} - -#[cfg(feature = "embedded-graphics")] -impl From for State { - fn from(value: BinaryColor) -> Self { - match value { - BinaryColor::Off => Self::Off, - BinaryColor::On => Self::On, - } - } -} - -#[cfg(feature = "embedded-graphics")] -impl Dimensions for MessageBuffer<'_> { - fn bounding_box(&self) -> embedded_graphics::primitives::Rectangle { - Rectangle::new( - Point::zero(), - Size::new((self.0.len() * 8).try_into().unwrap(), 11), - ) - } -} - -#[cfg(feature = "embedded-graphics")] -impl DrawTarget for MessageBuffer<'_> { - type Color = BinaryColor; - - type Error = std::convert::Infallible; - - fn draw_iter(&mut self, pixels: I) -> Result<(), Self::Error> - where - I: IntoIterator>, - { - for Pixel(point, color) in pixels { - #[allow(clippy::manual_assert)] - if self.set_embedded_graphics(point, color).is_none() { - panic!( - "tried to draw pixel outside the display area (x: {}, y: {})", - point.x, point.y - ); - } - } - Ok(()) - } -} - -#[cfg(test)] -mod test { - use std::ops::Range; - - use super::{Brightness, Speed}; - - #[test] - fn speed_to_u8_and_back() { - const VALID_SPEED_VALUES: Range = 1..8; - for i in u8::MIN..u8::MAX { - if let Ok(speed) = Speed::try_from(i) { - assert_eq!(u8::from(speed), i); - } else { - assert!(!VALID_SPEED_VALUES.contains(&i)); - } - } - } - - #[test] - fn brightness_to_u8_and_back() { - const VALID_BRIGHTNESS_VALUES: [(Brightness, u8); 4] = [ - (Brightness::Full, 0x00), - (Brightness::ThreeQuarters, 0x10), - (Brightness::Half, 0x20), - (Brightness::OneQuarter, 0x30), - ]; - - for (value, raw) in VALID_BRIGHTNESS_VALUES { - assert_eq!(u8::from(value), raw); - assert_eq!(Brightness::try_from(raw).unwrap(), value); - } - } - -} From cc32d545d42f4f63459c75c2b018d423ee499677 Mon Sep 17 00:00:00 2001 From: Valentin Weber Date: Wed, 16 Jul 2025 22:07:57 +0200 Subject: [PATCH 5/9] chore: clearer initialisation of an empty header --- src/protocol.rs | 556 ++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 556 insertions(+) create mode 100644 src/protocol.rs diff --git a/src/protocol.rs b/src/protocol.rs new file mode 100644 index 0000000..056a202 --- /dev/null +++ b/src/protocol.rs @@ -0,0 +1,556 @@ +//! Protocol used to update the badge + +use std::num::TryFromIntError; + +#[cfg(feature = "embedded-graphics")] +use embedded_graphics::{ + draw_target::DrawTarget, + geometry::{Dimensions, Point, Size}, + pixelcolor::BinaryColor, + prelude::Pixel, + primitives::Rectangle, + Drawable, +}; +use time::OffsetDateTime; +use zerocopy::{BigEndian, FromBytes, Immutable, IntoBytes, KnownLayout, U16}; + +/// Message style configuration +/// ``` +/// use badgemagic::protocol::{Mode, Style}; +/// # ( +/// Style::default().blink().border().mode(Mode::Center) +/// # ); +/// ``` +#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, Hash)] +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +#[cfg_attr(feature = "serde", serde(deny_unknown_fields))] +#[must_use] +pub struct Style { + #[cfg_attr(feature = "serde", serde(default))] + blink: bool, + + #[cfg_attr(feature = "serde", serde(default))] + border: bool, + + #[cfg_attr(feature = "serde", serde(default))] + speed: Speed, + + #[cfg_attr(feature = "serde", serde(default))] + mode: Mode, +} + +impl Style { + /// Enable blink mode + /// + /// The message will blink. + /// ``` + /// use badgemagic::protocol::Style; + /// # ( + /// Style::default().blink() + /// # ); + /// ``` + pub fn blink(mut self) -> Self { + self.blink = true; + self + } + + /// Show a dotted border around the display. + /// ``` + /// use badgemagic::protocol::Style; + /// # ( + /// Style::default().blink() + /// # ); + /// ``` + pub fn border(mut self) -> Self { + self.border = true; + self + } + + /// Set the update speed of the animations. + /// + /// The animation will jump to the next pixel at the specified frame rate. + /// ``` + /// use badgemagic::protocol::{Speed, Style}; + /// # ( + /// Style::default().speed(Speed::Fps1_2) + /// # ); + /// ``` + pub fn speed(mut self, speed: Speed) -> Self { + self.speed = speed; + self + } + + /// Set the display animation. + /// ``` + /// use badgemagic::protocol::{Mode, Style}; + /// # ( + /// Style::default().mode(Mode::Curtain) + /// # ); + /// ``` + /// + /// Show text centered, without an animation: + /// ``` + /// use badgemagic::protocol::{Mode, Style}; + /// # ( + /// Style::default().mode(Mode::Center) + /// # ); + /// ``` + pub fn mode(mut self, mode: Mode) -> Self { + self.mode = mode; + self + } +} + +/// Animation update speed +#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, Hash)] +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +#[cfg_attr(feature = "serde", serde(try_from = "u8", into = "u8"))] +pub enum Speed { + /// 1.2 FPS + Fps1_2, + + /// 1.3 FPS + Fps1_3, + + /// 2 FPS + Fps2, + + /// 2.4 FPS + Fps2_4, + + /// 2.8 FPS + #[default] + Fps2_8, + + /// 4.5 FPS + Fps4_5, + + /// 7.5 FPS + Fps7_5, + + /// 15 FPS + Fps15, +} + +impl From for u8 { + fn from(value: Speed) -> Self { + value as u8 + } +} + +impl TryFrom for Speed { + type Error = TryFromIntError; + + fn try_from(value: u8) -> Result { + Ok(match value { + 0 => Self::Fps1_2, + 1 => Self::Fps1_3, + 2 => Self::Fps2, + 3 => Self::Fps2_4, + 4 => Self::Fps2_8, + 5 => Self::Fps4_5, + 6 => Self::Fps7_5, + 7 => Self::Fps15, + _ => return Err(u8::try_from(-1).unwrap_err()), + }) + } +} + +/// Message display mode +#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, Hash)] +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +#[cfg_attr(feature = "serde", serde(rename_all = "snake_case"))] +pub enum Mode { + /// Scroll through the message from left to right + #[default] + Left, + + /// Scroll through the message from right to left + Right, + + /// Enter from the bottom, move up + Up, + + /// Enter from the top, move down + Down, + + /// Center the text, no animation + Center, + + /// Fast mode for animations + /// + /// Will leave a 4 pixel gap between screens: + /// Place a 44x11 pixel screen every 48 pixels + Fast, + + /// Drop rows of pixels from the top + Drop, + + /// Open a curtain and reveal the message + Curtain, + + /// A laser will reveal the message from left to right + Laser, +} + +/// Display Brightness +#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, Hash)] +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +#[cfg_attr(feature = "serde", serde(rename_all = "snake_case"))] +pub enum Brightness { + #[default] + Full = 0x00, + ThreeQuarters = 0x10, + Half = 0x20, + OneQuarter = 0x30, +} + +impl From for u8 { + fn from(value: Brightness) -> Self { + value as u8 + } +} + +impl TryFrom for Brightness { + type Error = TryFromIntError; + + fn try_from(value: u8) -> Result { + Ok(match value { + 0x00 => Self::Full, + 0x10 => Self::ThreeQuarters, + 0x20 => Self::Half, + 0x30 => Self::OneQuarter, + _ => return Err(u8::try_from(-1).unwrap_err()), + }) + } +} + +const MSG_PADDING_ALIGN: usize = 64; + +const MAGIC: [u8; 5] = *b"wang\0"; + +#[derive(FromBytes, IntoBytes, Immutable, KnownLayout)] +#[repr(C)] +struct Header { + magic: [u8; 5], + brightness: u8, + blink: u8, + border: u8, + speed_and_mode: [u8; 8], + message_length: [U16; 8], + _padding_1: [u8; 6], + timestamp: Timestamp, + _padding_2: [u8; 20], +} + +#[derive(FromBytes, IntoBytes, Immutable, KnownLayout)] +#[repr(C)] +struct Timestamp { + year: u8, + month: u8, + day: u8, + hour: u8, + minute: u8, + second: u8, +} + +impl Timestamp { + fn new(ts: OffsetDateTime) -> Self { + Self { + #[allow(clippy::cast_possible_truncation)] // clippy does not understand `rem_euclid(100) <= 100` + year: ts.year().rem_euclid(100) as u8, + month: ts.month() as u8, + day: ts.day(), + hour: ts.hour(), + minute: ts.minute(), + second: ts.second(), + } + } + + fn now() -> Self { + Self::new(OffsetDateTime::now_utc()) + } +} + +/// Buffer to create a payload +/// +/// A payload consists of up to 8 messages +/// ``` +/// # #[cfg(feature = "embedded-graphics")] +/// # fn main() { +/// # use badgemagic::protocol::{PayloadBuffer, Style}; +/// use badgemagic::embedded_graphics::{ +/// geometry::{Point, Size}, +/// pixelcolor::BinaryColor, +/// primitives::{PrimitiveStyle, Rectangle, Styled}, +/// }; +/// +/// let mut buffer = PayloadBuffer; +/// buffer.add_message_drawable( +/// Style::default(), +/// &Styled::new( +/// Rectangle::new(Point::new(2, 2), Size::new(4, 7)), +/// PrimitiveStyle::with_fill(BinaryColor::On), +/// ), +/// ); +/// # } +/// # #[cfg(not(feature = "embedded-graphics"))] +/// # fn main() {} +/// ``` +pub struct PayloadBuffer { + num_messages: u8, + data: Vec, +} + +impl Default for PayloadBuffer { + fn default() -> Self { + Self::new() + } +} + +impl PayloadBuffer { + /// Create a new empty buffer + #[must_use] + pub fn new() -> Self { + Self { + num_messages: 0, + data: Header { + magic: MAGIC, + brightness: Brightness::Full.into(), + blink: 0, + border: 0, + speed_and_mode: [0; 8], + message_length: [0.into(); 8], + _padding_1: [0; 6], + timestamp: Timestamp::now(), + _padding_2: [0; 20], + } + .as_bytes() + .into(), + } + } + + fn header_mut(&mut self) -> &mut Header { + Header::mut_from_prefix(&mut self.data).unwrap().0 + } + + pub fn set_brightness(&mut self, brightness: Brightness) { + self.header_mut().brightness = brightness.into(); + } + + /// Return the current number of messages + pub fn num_messages(&mut self) -> usize { + self.num_messages as usize + } + + /// Add a messages containing the specified `content` + /// + /// ## Panics + /// This method panics if it is unable to draw the content. + #[cfg(feature = "embedded-graphics")] + pub fn add_message_drawable( + &mut self, + style: Style, + content: &(impl Drawable + Dimensions), + ) -> O { + #[allow(clippy::cast_possible_wrap)] + fn saturating_usize_to_isize(n: usize) -> isize { + usize::min(n, isize::MAX as usize) as isize + } + + fn add(a: i32, b: u32) -> usize { + let result = a as isize + saturating_usize_to_isize(b as usize); + result.try_into().unwrap_or_default() + } + + let bounds = content.bounding_box(); + let width = add(bounds.top_left.x, bounds.size.width); + let mut message = self.add_message(style, width.div_ceil(8)); + content.draw(&mut message).unwrap() + } + + /// Add a message with `count * 8` columns + /// + /// The returned `MessageBuffer` can be used as an `embedded_graphics::DrawTarget` + /// with the `embedded_graphics` feature. + /// + /// ## Panics + /// Panics if the supported number of messages is reached. + pub fn add_message(&mut self, style: Style, count: usize) -> MessageBuffer { + let index = self.num_messages as usize; + assert!( + index < 8, + "maximum number of supported messages reached: {index} messages", + ); + self.num_messages += 1; + + let header = self.header_mut(); + + if style.blink { + header.blink |= 1 << index; + } + if style.border { + header.border |= 1 << index; + } + header.speed_and_mode[index] = ((style.speed as u8) << 4) | style.mode as u8; + header.message_length[index] = count.try_into().unwrap(); + + let start = self.data.len(); + self.data.resize(start + count * 11, 0); + MessageBuffer(FromBytes::mut_from_bytes(&mut self.data[start..]).unwrap()) + } + + /// Get the current payload as bytes (without padding) + #[must_use] + pub fn as_bytes(&self) -> &[u8] { + &self.data + } + + /// 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]> { + let mut data = self.data; + + let prev_len = data.len(); + + // pad msg to align to 64 bytes + data.resize( + (data.len() + (MSG_PADDING_ALIGN - 1)) & !(MSG_PADDING_ALIGN - 1), + 0, + ); + + // validate alignment + assert_eq!(data.len() % 64, 0); + assert!(prev_len <= data.len()); + + data + } +} + +/// A display buffer for a single message. +/// +/// Can be used as an `embedded_graphics::DrawTarget`. +pub struct MessageBuffer<'a>(&'a mut [[u8; 11]]); + +impl MessageBuffer<'_> { + /// Set the state of the pixel at point (`x`, `y`) + /// + /// Returns `None` if the pixel was out of bounds. + pub fn set(&mut self, (x, y): (usize, usize), state: State) -> Option<()> { + let byte = self.0.get_mut(x / 8)?.get_mut(y)?; + let bit = 0x80 >> (x % 8); + match state { + State::Off => { + *byte &= !bit; + } + State::On => { + *byte |= bit; + } + } + Some(()) + } + + #[cfg(feature = "embedded-graphics")] + fn set_embedded_graphics(&mut self, point: Point, color: BinaryColor) -> Option<()> { + let x = point.x.try_into().ok()?; + let y = point.y.try_into().ok()?; + self.set((x, y), color.into()) + } +} + +/// State of a pixel +#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub enum State { + #[default] + Off, + On, +} + +impl From for State { + fn from(value: bool) -> Self { + if value { + Self::On + } else { + Self::Off + } + } +} + +#[cfg(feature = "embedded-graphics")] +impl From for State { + fn from(value: BinaryColor) -> Self { + match value { + BinaryColor::Off => Self::Off, + BinaryColor::On => Self::On, + } + } +} + +#[cfg(feature = "embedded-graphics")] +impl Dimensions for MessageBuffer<'_> { + fn bounding_box(&self) -> embedded_graphics::primitives::Rectangle { + Rectangle::new( + Point::zero(), + Size::new((self.0.len() * 8).try_into().unwrap(), 11), + ) + } +} + +#[cfg(feature = "embedded-graphics")] +impl DrawTarget for MessageBuffer<'_> { + type Color = BinaryColor; + + type Error = std::convert::Infallible; + + fn draw_iter(&mut self, pixels: I) -> Result<(), Self::Error> + where + I: IntoIterator>, + { + for Pixel(point, color) in pixels { + #[allow(clippy::manual_assert)] + if self.set_embedded_graphics(point, color).is_none() { + panic!( + "tried to draw pixel outside the display area (x: {}, y: {})", + point.x, point.y + ); + } + } + Ok(()) + } +} + +#[cfg(test)] +mod test { + use std::ops::Range; + + use super::{Brightness, Speed}; + + #[test] + fn speed_to_u8_and_back() { + const VALID_SPEED_VALUES: Range = 1..8; + for i in u8::MIN..u8::MAX { + if let Ok(speed) = Speed::try_from(i) { + assert_eq!(u8::from(speed), i); + } else { + assert!(!VALID_SPEED_VALUES.contains(&i)); + } + } + } + + #[test] + fn brightness_to_u8_and_back() { + const VALID_BRIGHTNESS_VALUES: [(Brightness, u8); 4] = [ + (Brightness::Full, 0x00), + (Brightness::ThreeQuarters, 0x10), + (Brightness::Half, 0x20), + (Brightness::OneQuarter, 0x30), + ]; + + for (value, raw) in VALID_BRIGHTNESS_VALUES { + assert_eq!(u8::from(value), raw); + assert_eq!(Brightness::try_from(raw).unwrap(), value); + } + } + +} From cd31980b6b1ec91173074f948e11938beb488325 Mon Sep 17 00:00:00 2001 From: Valentin Weber Date: Wed, 16 Jul 2025 22:12:02 +0200 Subject: [PATCH 6/9] test: fix creation of PayloadBuffer --- src/protocol.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/protocol.rs b/src/protocol.rs index 056a202..c92e448 100644 --- a/src/protocol.rs +++ b/src/protocol.rs @@ -285,7 +285,7 @@ impl Timestamp { /// primitives::{PrimitiveStyle, Rectangle, Styled}, /// }; /// -/// let mut buffer = PayloadBuffer; +/// let mut buffer = PayloadBuffer::new(); /// buffer.add_message_drawable( /// Style::default(), /// &Styled::new( From 9803b7e4da71bff350be131928c8d4d1477d82ba Mon Sep 17 00:00:00 2001 From: Valentin Weber Date: Wed, 16 Jul 2025 22:40:20 +0200 Subject: [PATCH 7/9] test: simplify test for brightness --- src/protocol.rs | 17 +++++++---------- 1 file changed, 7 insertions(+), 10 deletions(-) diff --git a/src/protocol.rs b/src/protocol.rs index c92e448..9c3472d 100644 --- a/src/protocol.rs +++ b/src/protocol.rs @@ -540,16 +540,13 @@ mod test { #[test] fn brightness_to_u8_and_back() { - const VALID_BRIGHTNESS_VALUES: [(Brightness, u8); 4] = [ - (Brightness::Full, 0x00), - (Brightness::ThreeQuarters, 0x10), - (Brightness::Half, 0x20), - (Brightness::OneQuarter, 0x30), - ]; - - for (value, raw) in VALID_BRIGHTNESS_VALUES { - assert_eq!(u8::from(value), raw); - assert_eq!(Brightness::try_from(raw).unwrap(), value); + const VALID_BRIGHTNESS_VALUES: [u8; 4] = [0x00, 0x10, 0x20, 0x30]; + for i in u8::MIN..u8::MAX { + if let Ok(brightness) = Brightness::try_from(i) { + assert_eq!(u8::from(brightness), i); + } else { + assert!(!VALID_BRIGHTNESS_VALUES.contains(&i)); + } } } From caf95f58031062681d9920429b08f424248f2439 Mon Sep 17 00:00:00 2001 From: Valentin Weber Date: Wed, 16 Jul 2025 22:49:28 +0200 Subject: [PATCH 8/9] chore: cargo fmt --- src/protocol.rs | 1 - 1 file changed, 1 deletion(-) diff --git a/src/protocol.rs b/src/protocol.rs index 9c3472d..e86a6a0 100644 --- a/src/protocol.rs +++ b/src/protocol.rs @@ -549,5 +549,4 @@ mod test { } } } - } From 9594fe685aabe810cd46a42157498502b37743a0 Mon Sep 17 00:00:00 2001 From: Valentin Weber Date: Fri, 18 Jul 2025 22:02:15 +0200 Subject: [PATCH 9/9] feat: switch to floating point numbers for brightness config --- src/protocol.rs | 44 +++++++++++++++++++++++++++++++++++++++----- 1 file changed, 39 insertions(+), 5 deletions(-) diff --git a/src/protocol.rs b/src/protocol.rs index e86a6a0..32c41e8 100644 --- a/src/protocol.rs +++ b/src/protocol.rs @@ -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::{convert::Infallible, num::TryFromIntError}; use time::OffsetDateTime; use zerocopy::{BigEndian, FromBytes, Immutable, IntoBytes, KnownLayout, U16}; @@ -196,7 +195,7 @@ pub enum Mode { /// Display Brightness #[derive(Debug, Default, Clone, Copy, PartialEq, Eq, Hash)] #[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] -#[cfg_attr(feature = "serde", serde(rename_all = "snake_case"))] +#[cfg_attr(feature = "serde", serde(try_from = "f32", into = "u8"))] pub enum Brightness { #[default] Full = 0x00, @@ -213,7 +212,6 @@ impl From for u8 { impl TryFrom for Brightness { type Error = TryFromIntError; - fn try_from(value: u8) -> Result { Ok(match value { 0x00 => Self::Full, @@ -225,6 +223,32 @@ impl TryFrom for Brightness { } } +impl From for f32 { + fn from(value: Brightness) -> Self { + match value { + Brightness::Full => 1.0, + Brightness::ThreeQuarters => 0.75, + Brightness::Half => 0.5, + Brightness::OneQuarter => 0.5, + } + } +} + +impl TryFrom for Brightness { + type Error = Infallible; + fn try_from(value: f32) -> Result { + if value < 0.375 { + Ok(Self::OneQuarter) + } else if value < 0.625 { + Ok(Self::Half) + } else if value < 0.875 { + Ok(Self::ThreeQuarters) + } else { + Ok(Self::Full) + } + } +} + const MSG_PADDING_ALIGN: usize = 64; const MAGIC: [u8; 5] = *b"wang\0"; @@ -539,7 +563,7 @@ mod test { } #[test] - fn brightness_to_u8_and_back() { + fn u8_to_brightness_and_back() { const VALID_BRIGHTNESS_VALUES: [u8; 4] = [0x00, 0x10, 0x20, 0x30]; for i in u8::MIN..u8::MAX { if let Ok(brightness) = Brightness::try_from(i) { @@ -549,4 +573,14 @@ mod test { } } } + + #[test] + fn f32_to_brightness_and_back() { + const VALID_BRIGHTNESS_VALUES: [f32; 4] = [0.25, 0.5, 0.75, 1.0]; + for i in i8::MIN..i8::MAX { + let i = i as f32 / 4f32; + let Ok(brightness) = Brightness::try_from(i); + assert!(VALID_BRIGHTNESS_VALUES.contains(&(f32::from(brightness)))); + } + } }