badgemagic-rs/src/protocol.rs
2024-06-07 20:04:53 +00:00

502 lines
12 KiB
Rust

//! 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<Speed> for u8 {
fn from(value: Speed) -> Self {
value as u8
}
}
impl TryFrom<u8> for Speed {
type Error = TryFromIntError;
fn try_from(value: u8) -> Result<Self, Self::Error> {
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<BigEndian>; 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<u8>,
}
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<O>(
&mut self,
style: Style,
content: &(impl Drawable<Color = BinaryColor, Output = O> + 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<bool> for State {
fn from(value: bool) -> Self {
if value {
Self::On
} else {
Self::Off
}
}
}
#[cfg(feature = "embedded-graphics")]
impl From<BinaryColor> 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<I>(&mut self, pixels: I) -> Result<(), Self::Error>
where
I: IntoIterator<Item = Pixel<Self::Color>>,
{
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<u8> = 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));
}
}
}
}