From 157f8f15f87022b4de9b44b93000a79c1a907ebc Mon Sep 17 00:00:00 2001 From: Lukrecja Date: Tue, 30 Dec 2025 00:53:54 +0100 Subject: [PATCH] add wpm calculation and cute puppygirl animations for the oled --- assets/happy-1.png | Bin 0 -> 486 bytes assets/happy-1.raw | Bin 0 -> 128 bytes assets/happy-2.png | Bin 0 -> 478 bytes assets/happy-2.raw | Bin 0 -> 128 bytes assets/idle-blink-1.png | Bin 0 -> 485 bytes assets/idle-blink-1.raw | Bin 0 -> 128 bytes assets/idle-blink-2.png | Bin 0 -> 485 bytes assets/idle-blink-2.raw | Bin 0 -> 128 bytes assets/idle-normal-1.png | Bin 0 -> 494 bytes assets/idle-normal-1.raw | Bin 0 -> 128 bytes assets/idle-normal-2.png | Bin 0 -> 492 bytes assets/idle-normal-2.raw | Bin 0 -> 128 bytes assets/sleep-1.png | Bin 0 -> 475 bytes assets/sleep-1.raw | Bin 0 -> 128 bytes assets/sleep-2.png | Bin 0 -> 480 bytes assets/sleep-2.raw | Bin 0 -> 128 bytes src/display.rs | 125 ++++++++++++++++++++++++++++----------- src/main.rs | 33 ++++++++--- src/wpm.rs | 55 +++++++++++++++++ 19 files changed, 172 insertions(+), 41 deletions(-) create mode 100644 assets/happy-1.png create mode 100644 assets/happy-1.raw create mode 100644 assets/happy-2.png create mode 100644 assets/happy-2.raw create mode 100644 assets/idle-blink-1.png create mode 100644 assets/idle-blink-1.raw create mode 100644 assets/idle-blink-2.png create mode 100644 assets/idle-blink-2.raw create mode 100644 assets/idle-normal-1.png create mode 100644 assets/idle-normal-1.raw create mode 100644 assets/idle-normal-2.png create mode 100644 assets/idle-normal-2.raw create mode 100644 assets/sleep-1.png create mode 100644 assets/sleep-1.raw create mode 100644 assets/sleep-2.png create mode 100644 assets/sleep-2.raw create mode 100644 src/wpm.rs diff --git a/assets/happy-1.png b/assets/happy-1.png new file mode 100644 index 0000000000000000000000000000000000000000..23c6d6936fa617ef57a21f9ce4d20c5a27d796ba GIT binary patch literal 486 zcmV@P);P0 ze&y%7{+7@4oVLB!cK5HS6hH-vP8FLH&=i0=+y(sRy$6E-kIZ*s-T*K*SLT>~0quYt zLoD_UKoW$pG%+{Dqzv>I)_=@)3Ya9&nRv2#G0JgY9fZFqS=Uoh57gnEjl??=|BwJ2 z@~P8V8C1N80qlu7d{Rp#vZqdMLh zipzwpC(M2H00!w2t1>C4oYp7SUI5(7t`u&4lJTelob9R%%#2t=cJHGJGLnG@ncNo`g4 cGur#!AIVi5PIxZDga7~l07*qoM6N<$f>c)CSpWb4 literal 0 HcmV?d00001 diff --git a/assets/happy-1.raw b/assets/happy-1.raw new file mode 100644 index 0000000000000000000000000000000000000000..3952cdbcd1a49c42127dbcbfa0e63552e420ab34 GIT binary patch literal 128 zcmV-`0Du1gzzhHb|Na06;2(ew;2*#bf{(xlhOdAFQm23jPym1kNCW^1NFX2z0r4PE zzxe>w|NRg#-`tQOudI+j|NMwSudFOdTGcQJ{rE5-Kk#594~IYp@PB{@_q%`x`Fnr^ i-}iz8&*uXH@9zfz|NjpF_uoDM_rCl9_r3T4fBf)I^*WpY literal 0 HcmV?d00001 diff --git a/assets/happy-2.png b/assets/happy-2.png new file mode 100644 index 0000000000000000000000000000000000000000..9faf8b5d78a32eeef8281364244a9126d032de9b GIT binary patch literal 478 zcmV<40U`d0P)nAN=W!hO z&sTo#``3J)r(5>6?XF*5DS&b`I#n#1fTjS{;mP5D_FV}66Or%ud;nl8>lveU4krP0Ccap{80EOG4#Hn-*;G@b8mPlN8x!rA=!XQ5 zkh@A}X;A(~3}8*v;mc`#*E>9962wc0*21EfwcC{Xo~i`r%_?&C{;G;k zf|4R(_6c);Y5<+|7OONVrF3@3+6zG1oKlhOu8nOM;A~fEU}D4yvUg8C$7$HRLjh7< z#M%;7_HiG;bhsJ6Gyf6%o1wZ9P3s`Fp;JL-dkiDY6Xusit?mka7RdT326|+>vpq?! z(-6tPblO#vC}atg?1GfzsudKe$v!H@O4;yzr_oKoB)%bJRJ3Xy^kT%ZEdjX7 z-l>n2nk@!WVL7|9SpbewS0iY1z_u80xmN;}gzF;!+#Mm+fc$qnRmRj2$N;AG8%?hq UNf$O!r2qf`07*qoM6N<$f=2?<>Hq)$ literal 0 HcmV?d00001 diff --git a/assets/happy-2.raw b/assets/happy-2.raw new file mode 100644 index 0000000000000000000000000000000000000000..7be7278f218195155755bf17da71455f3a0944df GIT binary patch literal 128 zcmV-`0Du1gzzhHb|Na06;2(ew;2*#bf{(xlhOdAFQm23jPym1kNCW^1NFX2z0r4PE zzxe>w|NRg#-`tQOudI+j|NMwSudFOdTGcQJ{rE5-Kk#594~IYp@PB{@_q%`x`FpSf i-}kr!&*vNg@9!J||NlGy_uqT~_rCZ5_r3T4fBf)fY&(|# literal 0 HcmV?d00001 diff --git a/assets/idle-blink-1.png b/assets/idle-blink-1.png new file mode 100644 index 0000000000000000000000000000000000000000..c93a831482f6c382556c52c89b5dda00781bbbe1 GIT binary patch literal 485 zcmVF2sW=JPzOV_)0!{PHRRgd;0za>xNK0c62Dhj-q4F!*0&KJmE$P;9QuIr{*LfIEhi z?+t+CM6pycSNW6-;0xO}ddT1xf0Gn%u^)&I{x*bNlS zgdJtJjMcOanJ#ot)sw+HwGR+)5rzgbdM6IMiOBY{3wXC@f>Ogs2@<>K9|M@VsxNj4 z4j0sqs<&!Op0eA0fXk6lTD4|1h&G+Yjp$c8m;_&JZp(=4#PwB-sNuyLNc9s8R60Q0 zWX>}P)nzFI?zRh2-kk;3GGca6$Do-Jq^+wIS1*3C|hVqY^l`Aw|NRg#$Bd95zx0qm|NMwSudFOdTGcQJ{rE5-Kk#594~IYp@PB{@_q%`x`Fnr^ i-}iz8&*uXH@9zfz|NjpF_uoDM_rCl9_r3T4fBf)C?K+GA literal 0 HcmV?d00001 diff --git a/assets/idle-blink-2.png b/assets/idle-blink-2.png new file mode 100644 index 0000000000000000000000000000000000000000..d31a3ede73d6c5755d018c07383a2867228f66c4 GIT binary patch literal 485 zcmV)-ha(#u@96l41yCW+ zl&;F);EF5Z5VN#xW0-JWnQcStDj&X(*dqc ze4at5F0%}Dr(KBhS1)J{BW4Gc+?^9Rv@l+IBT_VG6R-j_G9oTRHQCgw|NRg#$Bd95zx0qm|NMwSudFOdTGcQJ{rE5-Kk#594~IYp@PB{@_q%`x`FpSf i-}kr!&*vNg@9!J||NlGy_uqT~_rCZ5_r3T4fBf)ZWIKld literal 0 HcmV?d00001 diff --git a/assets/idle-normal-1.png b/assets/idle-normal-1.png new file mode 100644 index 0000000000000000000000000000000000000000..70876ff096cb79f30e994d24b60668dfe65ae061 GIT binary patch literal 494 zcmV}x6Y(cocG8rkflQ8UIK9K^A0>bZ zd8c$$hAN)L0Ba(XN6>u7Z=A$A#D61-jY+Svn@c^GDgjNRygEyv`cs%HAg@&?SHE9* zbJtL&Q=lFy3s<83&;TdG?@m3PJ&T=D0}ADnplH9;7YY{-*1CXadsPb6jFcb|L|%{6 z5ZzG$s$8UO$z67N9#E836LM$%Gx#?{p#jIUt`@p3sMeNY#C78ODj=$Pu?9;0Bm+qE z?rfjf>H0{?fIIEda@@!!P^AmH#HbZ4jhjrx9-oM~mI(sq1h--bQ0}qDu>w(9b7{5B zADRGB0_c>zqmQY*Z!s`YJFRE=5L64Gn*A>QY;6V@@gLbx)wya=Q@h?O!DLa_4Id9J ksuOY|r?!>)745k97pEu}UU7F;egFUf07*qoM6N<$fw$Bhs%hlG$Ir<9OD|NMwSudFOdTGcQJ{rE5-Kk#594~IYp@PB{@_q%`x`Fnr^ i-}iz8&*uXH@9zfz|NjpF_uoDM_rCl9_r3T4fBf*^emNun literal 0 HcmV?d00001 diff --git a/assets/idle-normal-2.png b/assets/idle-normal-2.png new file mode 100644 index 0000000000000000000000000000000000000000..8f19bef713532d34a2182c3b8eace9eb32c63915 GIT binary patch literal 492 zcmVFbo5<|9_dS+j8=Zow%o+gbrv)9LGuEJdWf3 zI_&Shf0obl?6$qucK0u<5yqZl3XbHeMpA5e9+{NJkBk~=a4*-hI6*)&=KoRi7 zkh1*%kQh-cbVuqMj+GFr#F$4-(Uz8lfnSoErLOR2|H6`)C!S65P~{t~7#$ZIZgb^oe| zyM{7FfqHNy>_q!V1MCQ|lYAz79_5T0P$=Iqise`OLg8XyZ58lrcd1a#NC^@_)cH6K zu{sLU@l*`Ly{dOttA#ek#Jy)kw7BL=4Yb?fPCoc%gZ iJ7dR6l`NMjUVj2Bf)`mVh+@Y80000w$Bhs%hlG$Ir<9OD|NMwSudFOdTGcQJ{rE5-Kk#594~IYp@PB{@_q%`x`FpSf i-}kr!&*vNg@9!J||NlGy_uqT~_rCZ5_r3T4fBf+F^*SE_ literal 0 HcmV?d00001 diff --git a/assets/sleep-1.png b/assets/sleep-1.png new file mode 100644 index 0000000000000000000000000000000000000000..5cfb5285cfd48f38621beb09b13a385c77330803 GIT binary patch literal 475 zcmV<10VMv3P)#UX5Y10`{9)W2uD^_VT7QWPYOT~Z8dZ46}a#p+tfUZ`Z({3HxI44?mf$O*`M;0XO zj=!FUm;jry2LR^~VTYE=oQ-E`J0$Rg4sxB~qr-Kpr> z-O@nkyjy1)C`&LyX-BmZ`>i%)V_6L-#YzBv1ORQX<_}H)YrDV^ff{)J!hOdAFQm23jPyn0=NCXTDNFXQ*0r4(S zzxe>w|NRg#|NM|3kBpE&|NMwS|NJaT|NSrs{rE5-Kk#594~IYp@PB{@_q%`x`Fnr^ i-}it6&*uOE@9zKs|Nj60_ul{j_rCl9_r3oBfBpZ`1w1_f literal 0 HcmV?d00001 diff --git a/assets/sleep-2.png b/assets/sleep-2.png new file mode 100644 index 0000000000000000000000000000000000000000..a83ce8ede4319ed759ab99f6b364901ea4139123 GIT binary patch literal 480 zcmV<60U!Q}P)K4mREM?3opRkt<#0%p;XPben5&)a9yQ_LB%GnMM_?IPH^;D||5InNc znH`<^VF6UgS2{iR&G3uwc-~@wdji4P8G}|yaF3jWXc&TCCATj1u~f*~$rsOWnWU7~ zE1f%;@^kpU1Ig+@=>SozG9w={oa*zM0r7cN*VX1d0iXw5R+ZE0oMjvnN>$KxoRveB zIQsq9ZK!0)Q}zVl=J(Z^ug0@u>HR|TZ6ELqhyi&8$Fpe}#VHrrQMGhIm831pLj>7e z=d*0LTbh#>TFXFp+kqv{x~rhpGU9s1PF~#;6QimGYMyrkDZ5NfOAKU=oSdsW7Jyr$ z)e`E~9)6Y>sGL{l3VaGc(RXEPW~^P3p_&bDO*>YQ$WH*M3@g0r4hK zzxfQ+|NR~@|NM|3kBpE&|NMwS|NJaT|NSrs{rE5-Kk#594~IYp@PB{@_q%`x`Fnr^ i-}it6&*uOE@9zKs|Nj60_ul{j_rCl9_r3oBfBpZ&*E}Nt literal 0 HcmV?d00001 diff --git a/src/display.rs b/src/display.rs index 9ae1926..eb8ec1b 100644 --- a/src/display.rs +++ b/src/display.rs @@ -1,15 +1,20 @@ +use embassy_time::{Duration, Instant, Timer}; use embedded_graphics::{ - mono_font::{MonoTextStyleBuilder, ascii::FONT_6X10}, - pixelcolor::BinaryColor, - prelude::Point, - text::{Baseline, Text}, - draw_target::DrawTarget, - Drawable, + Drawable, draw_target::DrawTarget, image::{Image, ImageRaw}, mono_font::{MonoTextStyleBuilder, iso_8859_1::FONT_10X20}, pixelcolor::BinaryColor, prelude::Point, text::{Baseline, Text} }; use ssd1306::{prelude::*, I2CDisplayInterface, Ssd1306, mode::BufferedGraphicsMode}; use embassy_rp::i2c::I2c; 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 pub type Display = Ssd1306< I2CInterface>, @@ -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 fn update_display( - display: &mut Option, - pressed_keys: &[(usize, usize, crate::keymap::Action)], -) -> Option<()> { +pub async fn run_display(display: &mut Option) -> Option<()> { if let Some(display) = display { - display.clear(BinaryColor::Off).ok()?; - - let text_style = MonoTextStyleBuilder::new() - .font(&FONT_6X10) - .text_color(BinaryColor::On) - .build(); - - if pressed_keys.is_empty() { - Text::with_baseline("No keys", Point::zero(), text_style, Baseline::Top) + let mut wpm = 0; + let mut frame = 0; + let mut blink_frame = 0; + let mut last_frame_ms = Instant::now(); + + loop { + // Choose state based on wpm + let (state, frame_delay_ms) = if wpm == 0 { + (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 = 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) .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) - .draw(display) - .ok()?; - - y_pos += 10; - } + display.flush().ok()?; + + Timer::after(Duration::from_millis(16)).await; } - - display.flush().ok()?; - Some(()) } else { None } diff --git a/src/main.rs b/src/main.rs index deb5fea..be1fc39 100644 --- a/src/main.rs +++ b/src/main.rs @@ -5,6 +5,7 @@ mod matrix; mod keymap; mod display; mod usb; +mod wpm; use defmt::{info, warn}; use embassy_executor::Spawner; @@ -15,9 +16,9 @@ use embassy_rp::{ peripherals::USB, usb::{Driver, InterruptHandler as UsbInterruptHandler}, }; -use embassy_time::{Duration, Timer}; +use embassy_time::{Duration, Instant, Timer}; 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 _}; @@ -26,7 +27,6 @@ bind_interrupts!(struct Irqs { USBCTRL_IRQ => UsbInterruptHandler; }); - #[embassy_executor::main] async fn main(_spawner: Spawner) -> () { let p = embassy_rp::init(Default::default()); @@ -70,6 +70,10 @@ async fn main(_spawner: Spawner) -> () { let mut previous_modifier: u8 = 0; let mut active_layer: usize = 0; + // WPM tracking + let mut wpm_tracker = BucketWpmTracker::new(); + let mut last_wpm_send = Instant::now(); + loop { 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 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 { info!("Keys: {:?}, Mods: 0x{:02X}", keycodes, modifier); } else { @@ -157,6 +165,13 @@ async fn main(_spawner: Spawner) -> () { 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; } }; @@ -166,5 +181,9 @@ async fn main(_spawner: Spawner) -> () { 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; } diff --git a/src/wpm.rs b/src/wpm.rs new file mode 100644 index 0000000..c7fb54c --- /dev/null +++ b/src/wpm.rs @@ -0,0 +1,55 @@ +use embassy_sync::{blocking_mutex::raw::ThreadModeRawMutex, channel::Channel}; + +// Channel for sending WPM data +pub static WPM_CHANNEL: Channel = 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 + } +} \ No newline at end of file