add wpm calculation and cute puppygirl animations for the oled
BIN
assets/happy-1.png
Normal file
|
After Width: | Height: | Size: 486 B |
BIN
assets/happy-1.raw
Normal file
BIN
assets/happy-2.png
Normal file
|
After Width: | Height: | Size: 478 B |
BIN
assets/happy-2.raw
Normal file
BIN
assets/idle-blink-1.png
Normal file
|
After Width: | Height: | Size: 485 B |
BIN
assets/idle-blink-1.raw
Normal file
BIN
assets/idle-blink-2.png
Normal file
|
After Width: | Height: | Size: 485 B |
BIN
assets/idle-blink-2.raw
Normal file
BIN
assets/idle-normal-1.png
Normal file
|
After Width: | Height: | Size: 494 B |
BIN
assets/idle-normal-1.raw
Normal file
BIN
assets/idle-normal-2.png
Normal file
|
After Width: | Height: | Size: 492 B |
BIN
assets/idle-normal-2.raw
Normal file
BIN
assets/sleep-1.png
Normal file
|
After Width: | Height: | Size: 475 B |
BIN
assets/sleep-1.raw
Normal file
BIN
assets/sleep-2.png
Normal file
|
After Width: | Height: | Size: 480 B |
BIN
assets/sleep-2.raw
Normal file
125
src/display.rs
@@ -1,15 +1,20 @@
|
|||||||
|
use embassy_time::{Duration, Instant, Timer};
|
||||||
use embedded_graphics::{
|
use embedded_graphics::{
|
||||||
mono_font::{MonoTextStyleBuilder, ascii::FONT_6X10},
|
Drawable, draw_target::DrawTarget, image::{Image, ImageRaw}, mono_font::{MonoTextStyleBuilder, iso_8859_1::FONT_10X20}, pixelcolor::BinaryColor, prelude::Point, text::{Baseline, Text}
|
||||||
pixelcolor::BinaryColor,
|
|
||||||
prelude::Point,
|
|
||||||
text::{Baseline, Text},
|
|
||||||
draw_target::DrawTarget,
|
|
||||||
Drawable,
|
|
||||||
};
|
};
|
||||||
use ssd1306::{prelude::*, I2CDisplayInterface, Ssd1306, mode::BufferedGraphicsMode};
|
use ssd1306::{prelude::*, I2CDisplayInterface, Ssd1306, mode::BufferedGraphicsMode};
|
||||||
use embassy_rp::i2c::I2c;
|
use embassy_rp::i2c::I2c;
|
||||||
use core::fmt::Write;
|
use core::fmt::Write;
|
||||||
|
|
||||||
|
use crate::wpm::WPM_CHANNEL;
|
||||||
|
|
||||||
|
// Helper enum for all possible animation states
|
||||||
|
enum PuppyAnim {
|
||||||
|
Sleep,
|
||||||
|
Idle,
|
||||||
|
Happy,
|
||||||
|
}
|
||||||
|
|
||||||
/// Display type encapsulating the I2C interface, its size and graphics mode
|
/// Display type encapsulating the I2C interface, its size and graphics mode
|
||||||
pub type Display = Ssd1306<
|
pub type Display = Ssd1306<
|
||||||
I2CInterface<I2c<'static, embassy_rp::peripherals::I2C0, embassy_rp::i2c::Async>>,
|
I2CInterface<I2c<'static, embassy_rp::peripherals::I2C0, embassy_rp::i2c::Async>>,
|
||||||
@@ -33,39 +38,91 @@ pub fn init_display(bus: I2c<'static, embassy_rp::peripherals::I2C0, embassy_rp:
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Temp function which updates the display with currently pressed keys
|
pub async fn run_display(display: &mut Option<Display>) -> Option<()> {
|
||||||
pub fn update_display(
|
|
||||||
display: &mut Option<Display>,
|
|
||||||
pressed_keys: &[(usize, usize, crate::keymap::Action)],
|
|
||||||
) -> Option<()> {
|
|
||||||
if let Some(display) = display {
|
if let Some(display) = display {
|
||||||
display.clear(BinaryColor::Off).ok()?;
|
let mut wpm = 0;
|
||||||
|
let mut frame = 0;
|
||||||
let text_style = MonoTextStyleBuilder::new()
|
let mut blink_frame = 0;
|
||||||
.font(&FONT_6X10)
|
let mut last_frame_ms = Instant::now();
|
||||||
.text_color(BinaryColor::On)
|
|
||||||
.build();
|
loop {
|
||||||
|
// Choose state based on wpm
|
||||||
if pressed_keys.is_empty() {
|
let (state, frame_delay_ms) = if wpm == 0 {
|
||||||
Text::with_baseline("No keys", Point::zero(), text_style, Baseline::Top)
|
(PuppyAnim::Sleep, 250)
|
||||||
|
} else if wpm < 60 {
|
||||||
|
// faster tail wagging, connected to wpm
|
||||||
|
let delay = (3000 / wpm.max(10)).clamp(100, 300);
|
||||||
|
(PuppyAnim::Idle, delay)
|
||||||
|
} else {
|
||||||
|
// even faster tail wagging!!
|
||||||
|
let delay = (4000 / wpm).clamp(16, 100);
|
||||||
|
(PuppyAnim::Happy, delay)
|
||||||
|
};
|
||||||
|
|
||||||
|
if last_frame_ms.elapsed().as_millis() >= frame_delay_ms {
|
||||||
|
// used for determining when to blink
|
||||||
|
blink_frame += 1;
|
||||||
|
// switch between tail left and right
|
||||||
|
frame = (frame + 1) % 2;
|
||||||
|
last_frame_ms = Instant::now();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Choose image based on current state and tail position
|
||||||
|
let image_data = match (state, frame) {
|
||||||
|
(PuppyAnim::Sleep, 0) => include_bytes!("../assets/sleep-1.raw"),
|
||||||
|
(PuppyAnim::Sleep, 1) => include_bytes!("../assets/sleep-2.raw"),
|
||||||
|
(PuppyAnim::Idle, 0) => {
|
||||||
|
// blink every once in a while
|
||||||
|
if blink_frame % 20 < 5 {
|
||||||
|
include_bytes!("../assets/idle-blink-1.raw")
|
||||||
|
} else {
|
||||||
|
include_bytes!("../assets/idle-normal-1.raw")
|
||||||
|
}
|
||||||
|
},
|
||||||
|
(PuppyAnim::Idle, 1) => {
|
||||||
|
if blink_frame % 20 < 5 {
|
||||||
|
include_bytes!("../assets/idle-blink-2.raw")
|
||||||
|
} else {
|
||||||
|
include_bytes!("../assets/idle-normal-2.raw")
|
||||||
|
}
|
||||||
|
},
|
||||||
|
(PuppyAnim::Happy, 0) => include_bytes!("../assets/happy-1.raw"),
|
||||||
|
(PuppyAnim::Happy, 1) => include_bytes!("../assets/happy-2.raw"),
|
||||||
|
_ => include_bytes!("../assets/idle-normal-1.raw"),
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
|
display.clear(BinaryColor::Off).ok()?;
|
||||||
|
|
||||||
|
// Load and draw the chosen image
|
||||||
|
let raw: ImageRaw<BinaryColor> = ImageRaw::new(image_data, 32);
|
||||||
|
let im = Image::new(&raw, Point::new(0, 0));
|
||||||
|
im.draw(display).ok()?;
|
||||||
|
|
||||||
|
let text_style = MonoTextStyleBuilder::new()
|
||||||
|
.font(&FONT_10X20)
|
||||||
|
.text_color(BinaryColor::On)
|
||||||
|
.build();
|
||||||
|
|
||||||
|
match WPM_CHANNEL.try_receive() {
|
||||||
|
Ok(current_wpm) => {
|
||||||
|
wpm = current_wpm;
|
||||||
|
},
|
||||||
|
Err(_) => {},
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut wpm_str = heapless::String::<32>::new();
|
||||||
|
write!(&mut wpm_str, "WPM: {}", wpm).ok();
|
||||||
|
|
||||||
|
// Print current WPM
|
||||||
|
Text::with_baseline(&wpm_str, Point::new(32 + 4, 6), text_style, Baseline::Top)
|
||||||
.draw(display)
|
.draw(display)
|
||||||
.ok()?;
|
.ok()?;
|
||||||
} else {
|
|
||||||
let mut y_pos = 0;
|
|
||||||
for (row_idx, col_idx, keycode) in pressed_keys.iter().take(3) {
|
|
||||||
let mut msg = heapless::String::<32>::new();
|
|
||||||
write!(&mut msg, "R{}C{}: 0x{:?}", row_idx, col_idx, keycode).ok();
|
|
||||||
|
|
||||||
Text::with_baseline(&msg, Point::new(0, y_pos), text_style, Baseline::Top)
|
display.flush().ok()?;
|
||||||
.draw(display)
|
|
||||||
.ok()?;
|
Timer::after(Duration::from_millis(16)).await;
|
||||||
|
|
||||||
y_pos += 10;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
display.flush().ok()?;
|
|
||||||
Some(())
|
|
||||||
} else {
|
} else {
|
||||||
None
|
None
|
||||||
}
|
}
|
||||||
|
|||||||
33
src/main.rs
@@ -5,6 +5,7 @@ mod matrix;
|
|||||||
mod keymap;
|
mod keymap;
|
||||||
mod display;
|
mod display;
|
||||||
mod usb;
|
mod usb;
|
||||||
|
mod wpm;
|
||||||
|
|
||||||
use defmt::{info, warn};
|
use defmt::{info, warn};
|
||||||
use embassy_executor::Spawner;
|
use embassy_executor::Spawner;
|
||||||
@@ -15,9 +16,9 @@ use embassy_rp::{
|
|||||||
peripherals::USB,
|
peripherals::USB,
|
||||||
usb::{Driver, InterruptHandler as UsbInterruptHandler},
|
usb::{Driver, InterruptHandler as UsbInterruptHandler},
|
||||||
};
|
};
|
||||||
use embassy_time::{Duration, Timer};
|
use embassy_time::{Duration, Instant, Timer};
|
||||||
use usbd_hid::descriptor::KeyboardReport;
|
use usbd_hid::descriptor::KeyboardReport;
|
||||||
use crate::keymap::{Action, LayerOperation, get_action};
|
use crate::{keymap::{Action, LayerOperation, get_action}, wpm::{BucketWpmTracker, WPM_CHANNEL}};
|
||||||
|
|
||||||
use {defmt_rtt as _, panic_probe as _};
|
use {defmt_rtt as _, panic_probe as _};
|
||||||
|
|
||||||
@@ -26,7 +27,6 @@ bind_interrupts!(struct Irqs {
|
|||||||
USBCTRL_IRQ => UsbInterruptHandler<USB>;
|
USBCTRL_IRQ => UsbInterruptHandler<USB>;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
||||||
#[embassy_executor::main]
|
#[embassy_executor::main]
|
||||||
async fn main(_spawner: Spawner) -> () {
|
async fn main(_spawner: Spawner) -> () {
|
||||||
let p = embassy_rp::init(Default::default());
|
let p = embassy_rp::init(Default::default());
|
||||||
@@ -70,6 +70,10 @@ async fn main(_spawner: Spawner) -> () {
|
|||||||
let mut previous_modifier: u8 = 0;
|
let mut previous_modifier: u8 = 0;
|
||||||
let mut active_layer: usize = 0;
|
let mut active_layer: usize = 0;
|
||||||
|
|
||||||
|
// WPM tracking
|
||||||
|
let mut wpm_tracker = BucketWpmTracker::new();
|
||||||
|
let mut last_wpm_send = Instant::now();
|
||||||
|
|
||||||
loop {
|
loop {
|
||||||
let scan_result = matrix.scan().await;
|
let scan_result = matrix.scan().await;
|
||||||
|
|
||||||
@@ -130,11 +134,15 @@ async fn main(_spawner: Spawner) -> () {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update display
|
|
||||||
display::update_display(&mut display, &pressed_keys);
|
|
||||||
|
|
||||||
// Send HID report if keycodes or modifiers changed
|
// Send HID report if keycodes or modifiers changed
|
||||||
if keycodes != previous_keycodes || modifier != previous_modifier {
|
if keycodes != previous_keycodes || modifier != previous_modifier {
|
||||||
|
// Detect new keypresses for WPM tracking
|
||||||
|
for keycode in keycodes {
|
||||||
|
if keycode != 0 && !previous_keycodes.contains(&keycode) {
|
||||||
|
wpm_tracker.add_keypress(Instant::now().as_millis());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if keycodes != [0; 6] || modifier != 0 {
|
if keycodes != [0; 6] || modifier != 0 {
|
||||||
info!("Keys: {:?}, Mods: 0x{:02X}", keycodes, modifier);
|
info!("Keys: {:?}, Mods: 0x{:02X}", keycodes, modifier);
|
||||||
} else {
|
} else {
|
||||||
@@ -157,6 +165,13 @@ async fn main(_spawner: Spawner) -> () {
|
|||||||
previous_modifier = modifier;
|
previous_modifier = modifier;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Send WPM to the channel every 100ms
|
||||||
|
if last_wpm_send.elapsed().as_millis() >= 100 {
|
||||||
|
let wpm = wpm_tracker.calculate_wpm(Instant::now().as_millis());
|
||||||
|
WPM_CHANNEL.try_send(wpm).ok();
|
||||||
|
last_wpm_send = Instant::now();
|
||||||
|
}
|
||||||
|
|
||||||
Timer::after(Duration::from_millis(10)).await;
|
Timer::after(Duration::from_millis(10)).await;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -166,5 +181,9 @@ async fn main(_spawner: Spawner) -> () {
|
|||||||
reader.run(false, request_handler).await;
|
reader.run(false, request_handler).await;
|
||||||
};
|
};
|
||||||
|
|
||||||
join(usb_fut, join(in_fut, out_fut)).await;
|
let disp_fut = async {
|
||||||
|
display::run_display(&mut display).await;
|
||||||
|
};
|
||||||
|
|
||||||
|
join(disp_fut, join(usb_fut, join(in_fut, out_fut))).await;
|
||||||
}
|
}
|
||||||
|
|||||||
55
src/wpm.rs
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
use embassy_sync::{blocking_mutex::raw::ThreadModeRawMutex, channel::Channel};
|
||||||
|
|
||||||
|
// Channel for sending WPM data
|
||||||
|
pub static WPM_CHANNEL: Channel<ThreadModeRawMutex, u64, 32> = Channel::new();
|
||||||
|
|
||||||
|
pub struct BucketWpmTracker {
|
||||||
|
buckets: [u8; 15], // 15 one-second buckets
|
||||||
|
current_second: u64,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl BucketWpmTracker {
|
||||||
|
pub fn new() -> Self {
|
||||||
|
Self {
|
||||||
|
buckets: [0; 15],
|
||||||
|
current_second: 0,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn add_keypress(&mut self, timestamp_ms: u64) {
|
||||||
|
let second = timestamp_ms / 1000;
|
||||||
|
let bucket_idx = (second % 15) as usize;
|
||||||
|
|
||||||
|
// On new second, clear old buckets
|
||||||
|
if second != self.current_second {
|
||||||
|
let seconds_elapsed = second.saturating_sub(self.current_second).min(15);
|
||||||
|
for i in 0..seconds_elapsed {
|
||||||
|
let idx = ((self.current_second + i + 1) % 15) as usize;
|
||||||
|
self.buckets[idx] = 0;
|
||||||
|
}
|
||||||
|
self.current_second = second;
|
||||||
|
}
|
||||||
|
|
||||||
|
self.buckets[bucket_idx] = self.buckets[bucket_idx].saturating_add(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn calculate_wpm(&mut self, current_time_ms: u64) -> u64 {
|
||||||
|
let current_second = current_time_ms / 1000;
|
||||||
|
|
||||||
|
// Clear buckets older than 15 seconds
|
||||||
|
if current_second != self.current_second {
|
||||||
|
let seconds_elapsed = current_second.saturating_sub(self.current_second).min(15);
|
||||||
|
for i in 0..seconds_elapsed {
|
||||||
|
let idx = ((self.current_second + i + 1) % 15) as usize;
|
||||||
|
self.buckets[idx] = 0;
|
||||||
|
}
|
||||||
|
self.current_second = current_second;
|
||||||
|
}
|
||||||
|
|
||||||
|
let total_chars: u32 = self.buckets.iter().map(|&x| x as u32).sum();
|
||||||
|
|
||||||
|
// extrapolate 15s of data to 60s
|
||||||
|
// WPM = (chars / 5 chars per word) * (60 seconds / 15 seconds)
|
||||||
|
((total_chars as f64 * 4f64) / 5f64) as u64
|
||||||
|
}
|
||||||
|
}
|
||||||