//! 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::{AsBytes, BigEndian, FromBytes, FromZeroes, 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 arround 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 thorugh 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, } const MSG_PADDING_ALIGN: usize = 64; const MAGIC: [u8; 6] = *b"wang\0\0"; #[derive(FromZeroes, FromBytes, AsBytes)] #[repr(C)] struct Header { magic: [u8; 6], blink: u8, border: u8, speed_and_mode: [u8; 8], message_length: [U16; 8], _padding_1: [u8; 6], timestamp: Timestamp, _padding_2: [u8; 20], } #[derive(FromZeroes, FromBytes, AsBytes)] #[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 consits 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::new(); /// 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, 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() } /// 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 + 7) / 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_slice_from(&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 buffe 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::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)); } } } }