mirror of
https://github.com/fossasia/badgemagic-rs
synced 2025-06-25 00:03:58 +00:00
502 lines
12 KiB
Rust
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));
|
|
}
|
|
}
|
|
}
|
|
}
|