Most user-programmable mechanical keyboards use an embedded OS named QMK, which has support for both AVR and ARM microcontrollers. Some keyboards alternatively use ZMK which adds support for Bluetooth.
However as it turns out, the Rust-on-ARM story is pretty fleshed out with the following projects:
- knurling-rs/defmt: Provides debug formatting/logging.
- knurling-rs/probe-run: Automates flashing microcontrollers and collecting logs.
- rust-embedded/cortex-m: Provides access to ARM Cortex registers, peripherals, etc.
- rtic-rs/cortex-m-rtic: Provides support for concurrently executing tasks using interrupts.
- embassy-rs/embassy: Provides support for concurrently executing tasks using the rust async infrastructure, along with async USB/UART/I2C interfaces.
For this I built an ergo keyboard (the corne v3) with RGB LEDs and OLED displays. For the controller(s) I went with nice!nanos, which use the nRF52840 SoC. The nRF52840 supports Bluetooth, but as I don’t move the keyboard often, I decided to assume the keyboard will be always wired.
This was my first time writing for a microcontroller using something other than Arduino and it was an incredibly fluid experience. I spent a grand total of zero seconds debugging memory related problems, all bugs I had ended up being logic issues that were easily fixed by logging with defmt.
Programming
Embassy
Initially I attempted using RTIC to provide support for concurrent tasks, however RTIC requires you to manage setting up interrupts (for UART, I2C, etc) yourself, and quickly this got annoying.
After finding embassy I quickly ported my code over to use it. Embassy uses rust’s async machinery to allow for true tasks that run forever (in rtic each task is a function triggered by an interrupt). Embassy provides async compatible interfaces for USB, UART, and I2C too.
Creating a UART interface in Embassy is as simple as:
1let uart_config = uarte::Config::default();2let irq = interrupt::take!(UARTE0_UART0);3let uart = uarte::Uarte::new(p.UARTE0, irq, p.P0_08, p.P1_04, uart_config);45uart.write(b"hello world").await?;6let mut buf = [0u8; 1];7uart.read(&mut buf).await?;1let uart_config = uarte::Config::default();2let irq = interrupt::take!(UARTE0_UART0);3let uart = uarte::Uarte::new(p.UARTE0, irq, p.P0_08, p.P1_04, uart_config);45uart.write(b"hello world").await?;6let mut buf = [0u8; 1];7uart.read(&mut buf).await?;To communicate between tasks, I use the channel provided by embassy to have the producer task wait for the consumer task to process messages if the queue is full.
Keyberon
The most important part of the keyboard is having it, well, work as a keyboard. Luckily I don’t have to implement all the state management of a keyboard myself, as the TeXitoi/keyberon project conveniently exists.
Keyberon was incredibly easy to work with as it splits itself up into the few logically separated components needed for a keyboard:
- Matrix polling
- Key debouncing
- Key event processing
- HID event generation
To get Keyberon working I simply had to:
Specify matrix pins and construct the matrix struct
I used a macro for this as it requires that the gpio pin values are partially moved from the peripherals struct (this allows the compiler to ensure there is only one user of each pin at compile time)
1macro_rules! build_matrix {2 ($p:ident) => {{3 use embassy_nrf::gpio::{Input, Level, OutputDrive, Pin, Pull};4 use keyberon::matrix::Matrix;5 Matrix::new(6 [7 Input::new($p.P0_31.degrade(), Pull::Up),8 Input::new($p.P0_29.degrade(), Pull::Up),9 Input::new($p.P0_02.degrade(), Pull::Up),10 Input::new($p.P1_15.degrade(), Pull::Up),11 Input::new($p.P1_13.degrade(), Pull::Up),12 Input::new($p.P1_11.degrade(), Pull::Up),13 ],14 [15 Output::new($p.P0_22.degrade(), Level::High, OutputDrive::Standard),16 Output::new($p.P0_24.degrade(), Level::High, OutputDrive::Standard),17 Output::new($p.P1_00.degrade(), Level::High, OutputDrive::Standard),18 Output::new($p.P0_11.degrade(), Level::High, OutputDrive::Standard),19 ],20 )21 .unwrap()22 }};23}1macro_rules! build_matrix {2 ($p:ident) => {{3 use embassy_nrf::gpio::{Input, Level, OutputDrive, Pin, Pull};4 use keyberon::matrix::Matrix;5 Matrix::new(6 [7 Input::new($p.P0_31.degrade(), Pull::Up),8 Input::new($p.P0_29.degrade(), Pull::Up),9 Input::new($p.P0_02.degrade(), Pull::Up),10 Input::new($p.P1_15.degrade(), Pull::Up),11 Input::new($p.P1_13.degrade(), Pull::Up),12 Input::new($p.P1_11.degrade(), Pull::Up),13 ],14 [15 Output::new($p.P0_22.degrade(), Level::High, OutputDrive::Standard),16 Output::new($p.P0_24.degrade(), Level::High, OutputDrive::Standard),17 Output::new($p.P1_00.degrade(), Level::High, OutputDrive::Standard),18 Output::new($p.P0_11.degrade(), Level::High, OutputDrive::Standard),19 ],20 )21 .unwrap()22 }};23}Specify my keyboard layout
Keyberon provides a nice macro for doing this. In the same file I also declare the hold-taps and chords I want to use.
1pub static LAYERS: Layers = keyberon::layout::layout! {2 {3 ['`' Q W E R T Y U I O P '\''],4 [LShift A S D F G H J K L ; RShift],5 [LCtrl Z X C V B N M , . / RCtrl],6 [n n n LGui {ALT_TAB} {L1_SP} {L2_SP} Enter BSpace n n n],7 [Escape {m(&[KeyCode::LAlt, KeyCode::X])} {m(&[KeyCode::Space, KeyCode::Grave])} Delete < {m(&[KeyCode::LShift, KeyCode::SColon])} > '\\' / '"' '\'' '_'],8 }9 {10 ['`' ! @ '{' '}' | '`' ~ '\\' n '"' n],11 [ t # $ '(' ')' n + - / * '\'' t],12 [ t % ^ '[' ']' n & = , . '_' t],13 [n n n LGui LAlt = = Tab BSpace n n n],14 [n n n n n n n n n n n n],15 }16 {17 [n Kb1 Kb2 Kb3 Kb4 Kb5 Kb6 Kb7 Kb8 Kb9 Kb0 n],18 [t F1 F2 F3 F4 F5 Left Down Up Right VolUp t],19 [t F6 F7 F8 F9 F10 PgDown {m(&[KeyCode::LCtrl, KeyCode::Down])} {m(&[KeyCode::LCtrl, KeyCode::Up])} PgUp VolDown t],20 [n n n F11 F12 t t RAlt End n n n],21 [n n n n n n n n n n n n],22 }23};1pub static LAYERS: Layers = keyberon::layout::layout! {2 {3 ['`' Q W E R T Y U I O P '\''],4 [LShift A S D F G H J K L ; RShift],5 [LCtrl Z X C V B N M , . / RCtrl],6 [n n n LGui {ALT_TAB} {L1_SP} {L2_SP} Enter BSpace n n n],7 [Escape {m(&[KeyCode::LAlt, KeyCode::X])} {m(&[KeyCode::Space, KeyCode::Grave])} Delete < {m(&[KeyCode::LShift, KeyCode::SColon])} > '\\' / '"' '\'' '_'],8 }9 {10 ['`' ! @ '{' '}' | '`' ~ '\\' n '"' n],11 [ t # $ '(' ')' n + - / * '\'' t],12 [ t % ^ '[' ']' n & = , . '_' t],13 [n n n LGui LAlt = = Tab BSpace n n n],14 [n n n n n n n n n n n n],15 }16 {17 [n Kb1 Kb2 Kb3 Kb4 Kb5 Kb6 Kb7 Kb8 Kb9 Kb0 n],18 [t F1 F2 F3 F4 F5 Left Down Up Right VolUp t],19 [t F6 F7 F8 F9 F10 PgDown {m(&[KeyCode::LCtrl, KeyCode::Down])} {m(&[KeyCode::LCtrl, KeyCode::Up])} PgUp VolDown t],20 [n n n F11 F12 t t RAlt End n n n],21 [n n n n n n n n n n n n],22 }23};Poll the matrix
Keyberon provides the matrix struct, but won’t handle polling the matrix at an interval for us. For that I use an embassy task to poll the matrix every POLL_PERIOD and feed the results through the debouncer and chording engine. The processed key events are then pushed to a channel for processing by the layout task.
1#[embassy::task]2async fn keyboard_poll_task(3 mut matrix: Matrix<Input<'static, AnyPin>, Output<'static, AnyPin>, COLS_PER_SIDE, ROWS>,4 mut debouncer: Debouncer<[[bool; COLS_PER_SIDE]; ROWS]>,5 mut chording: Chording<{ keyboard_thing::layout::NUM_CHORDS }>,6) {7 loop {8 let events = debouncer9 .events(matrix.get().unwrap())10 .collect::<heapless::Vec<_, 8>>();1112 for event in &events {13 for chan in KEY_EVENT_CHANS {14 let _ = chan.try_send(*event);15 }16 }1718 let events = chording.tick(events);1920 let count = events.iter().filter(|e| e.is_press()).count() as u32;21 TOTAL_LHS_KEYPRESSES.fetch_add(count, core::sync::atomic::Ordering::Relaxed);2223 for event in events {24 PROCESSED_KEY_CHAN.send(event).await;25 }2627 Timer::after(POLL_PERIOD).await;28 }29}1#[embassy::task]2async fn keyboard_poll_task(3 mut matrix: Matrix<Input<'static, AnyPin>, Output<'static, AnyPin>, COLS_PER_SIDE, ROWS>,4 mut debouncer: Debouncer<[[bool; COLS_PER_SIDE]; ROWS]>,5 mut chording: Chording<{ keyboard_thing::layout::NUM_CHORDS }>,6) {7 loop {8 let events = debouncer9 .events(matrix.get().unwrap())10 .collect::<heapless::Vec<_, 8>>();1112 for event in &events {13 for chan in KEY_EVENT_CHANS {14 let _ = chan.try_send(*event);15 }16 }1718 let events = chording.tick(events);1920 let count = events.iter().filter(|e| e.is_press()).count() as u32;21 TOTAL_LHS_KEYPRESSES.fetch_add(count, core::sync::atomic::Ordering::Relaxed);2223 for event in events {24 PROCESSED_KEY_CHAN.send(event).await;25 }2627 Timer::after(POLL_PERIOD).await;28 }29}Push events through the layout
As before with the matrix, we decide when the layout state should be updated. For that another task is used to update the matrix state when a key is pressed or released. This task also receives the key events from the other half.
1#[embassy::task]2async fn keyboard_event_task(layout: &'static Mutex<ThreadModeRawMutex, Layout>) {3 loop {4 let event = PROCESSED_KEY_CHAN.recv().await;5 let mut count = if event.is_press() { 1 } else { 0 };6 if event.is_press() {7 KEYPRESS_EVENT.set();8 }9 interacted();10 {11 let mut layout = layout.lock().await;12 layout.event(event);13 while let Ok(event) = PROCESSED_KEY_CHAN.try_recv() {14 layout.event(event);15 count += if event.is_press() { 1 } else { 0 };16 }17 }18 TOTAL_KEYPRESSES.fetch_add(count, core::sync::atomic::Ordering::Relaxed);19 }20}1#[embassy::task]2async fn keyboard_event_task(layout: &'static Mutex<ThreadModeRawMutex, Layout>) {3 loop {4 let event = PROCESSED_KEY_CHAN.recv().await;5 let mut count = if event.is_press() { 1 } else { 0 };6 if event.is_press() {7 KEYPRESS_EVENT.set();8 }9 interacted();10 {11 let mut layout = layout.lock().await;12 layout.event(event);13 while let Ok(event) = PROCESSED_KEY_CHAN.try_recv() {14 layout.event(event);15 count += if event.is_press() { 1 } else { 0 };16 }17 }18 TOTAL_KEYPRESSES.fetch_add(count, core::sync::atomic::Ordering::Relaxed);19 }20}Extract keycode events and submit to the computer
To extract keycode events we use another task that extracts which keys are currently pressed every 1ms and submits the event to the task handling the USB HID messaging.
1#[embassy::task]2async fn layout_task(layout: &'static Mutex<ThreadModeRawMutex, Layout>) {3 let mut last_report = None;4 loop {5 {6 let mut layout = layout.lock().await;7 layout.tick();89 let collect = layout10 .keycodes()11 .filter_map(|k| Keyboard::try_from_primitive(k as u8).ok())12 .collect::<heapless::Vec<_, 24>>();1314 if last_report.as_ref() != Some(&collect) {15 last_report = Some(collect.clone());16 HID_CHAN.send(NKROBootKeyboardReport::new(&collect)).await;17 }18 }1920 Timer::after(Duration::from_millis(1)).await;21 }22}1#[embassy::task]2async fn layout_task(layout: &'static Mutex<ThreadModeRawMutex, Layout>) {3 let mut last_report = None;4 loop {5 {6 let mut layout = layout.lock().await;7 layout.tick();89 let collect = layout10 .keycodes()11 .filter_map(|k| Keyboard::try_from_primitive(k as u8).ok())12 .collect::<heapless::Vec<_, 24>>();1314 if last_report.as_ref() != Some(&collect) {15 last_report = Some(collect.clone());16 HID_CHAN.send(NKROBootKeyboardReport::new(&collect)).await;17 }18 }1920 Timer::after(Duration::from_millis(1)).await;21 }22}Tie everything together
First the required things are initialized, then we can start the tasks that perform all the keyboard processing.
1let matrix = keyboard_thing::build_matrix!(p);2let debouncer = Debouncer::new(3 [[false; COLS_PER_SIDE]; ROWS],4 [[false; COLS_PER_SIDE]; ROWS],5 DEBOUNCER_TICKS,6);7let chording = Chording::new(&keyboard_thing::layout::CHORDS);89let layout = forever!(Mutex::new(Layout::new(&keyboard_thing::layout::LAYERS)));1011spawner12 .spawn(keyboard_poll_task(matrix, debouncer, chording))13 .unwrap();14spawner.spawn(keyboard_event_task(layout)).unwrap();15spawner.spawn(layout_task(layout)).unwrap();1let matrix = keyboard_thing::build_matrix!(p);2let debouncer = Debouncer::new(3 [[false; COLS_PER_SIDE]; ROWS],4 [[false; COLS_PER_SIDE]; ROWS],5 DEBOUNCER_TICKS,6);7let chording = Chording::new(&keyboard_thing::layout::CHORDS);89let layout = forever!(Mutex::new(Layout::new(&keyboard_thing::layout::LAYERS)));1011spawner12 .spawn(keyboard_poll_task(matrix, debouncer, chording))13 .unwrap();14spawner.spawn(keyboard_event_task(layout)).unwrap();15spawner.spawn(layout_task(layout)).unwrap();NeoPixels
My Corne kit came with per-key and under-glow neopixels, so why not use them!
Fortunately the jamesmunns/nrf-smartled library exists for controlling neopixels by ab​using the PWM peripheral on the nRF52840. Now to take advantage of all 64Mhz we just need to define the positions of each LED (they’re connected serially):
1// underglow LEDs are left to right2#[rustfmt::skip]3pub const UNDERGLOW_LED_POSITIONS: [(u8, u8); UNDERGLOW_LEDS] = [4 // top row: 1, 2, 35 (0, 1), (2, 1), (4, 1),6 // bottom row: 4, 5, 67 (4, 2), (2, 3), (0, 3),8];910// switch leds are bottom to top11#[rustfmt::skip]12pub const SWITCH_LED_POSITIONS: [(u8, u8); SWITCH_LEDS] = [13 // first column: 7, 8, 9, 1014 (3, 5), (2, 5), (1, 5), (0, 5),15 // second column: 11, 12, 13, 1416 (0, 4), (1, 4), (2, 4), (3, 4),17 // third column: 15, 16, 17, 1818 (3, 3), (2, 3), (1, 3), (0, 3),19 // fourth column: 19, 20, 2120 (0, 2), (1, 2), (2, 2),21 // fifth column: 22, 23, 2422 (2, 1), (1, 1), (0, 1),23 // sixth column: 25, 26, 2724 (0, 0), (1, 0), (2, 0)25];1// underglow LEDs are left to right2#[rustfmt::skip]3pub const UNDERGLOW_LED_POSITIONS: [(u8, u8); UNDERGLOW_LEDS] = [4 // top row: 1, 2, 35 (0, 1), (2, 1), (4, 1),6 // bottom row: 4, 5, 67 (4, 2), (2, 3), (0, 3),8];910// switch leds are bottom to top11#[rustfmt::skip]12pub const SWITCH_LED_POSITIONS: [(u8, u8); SWITCH_LEDS] = [13 // first column: 7, 8, 9, 1014 (3, 5), (2, 5), (1, 5), (0, 5),15 // second column: 11, 12, 13, 1416 (0, 4), (1, 4), (2, 4), (3, 4),17 // third column: 15, 16, 17, 1818 (3, 3), (2, 3), (1, 3), (0, 3),19 // fourth column: 19, 20, 2120 (0, 2), (1, 2), (2, 2),21 // fifth column: 22, 23, 2422 (2, 1), (1, 1), (0, 1),23 // sixth column: 25, 26, 2724 (0, 0), (1, 0), (2, 0)25];And now we can create a fancy rainbow effect:
1pub fn rainbow_single(x: u8, y: u8, offset: u8) -> Hsv {2 Hsv {3 hue: x4 .wrapping_mul(6)5 .wrapping_add(y.wrapping_mul(2))6 .wrapping_add(offset),7 sat: 255,8 val: 127,9 }10}1112pub fn rainbow(offset: u8) -> impl Iterator<Item = RGB8> {13 colour_gen(move |x, y| hsv2rgb(rainbow_single(x, y, offset)))14}1pub fn rainbow_single(x: u8, y: u8, offset: u8) -> Hsv {2 Hsv {3 hue: x4 .wrapping_mul(6)5 .wrapping_add(y.wrapping_mul(2))6 .wrapping_add(offset),7 sat: 255,8 val: 127,9 }10}1112pub fn rainbow(offset: u8) -> impl Iterator<Item = RGB8> {13 colour_gen(move |x, y| hsv2rgb(rainbow_single(x, y, offset)))14}And use an embassy task to update and render it at 30fps.
1#[embassy::task]2async fn led_task(mut leds: Leds) {3 let fps = 30;4 let mut tapwaves = TapWaves::new();5 let mut ticker = Ticker::every(Duration::from_millis(1000 / fps));6 let mut counter = WrappingID::<u16>::new(0);78 loop {9 while let Ok(event) = LED_KEY_LISTEN_CHAN.try_recv() {10 tapwaves.update(event);11 }1213 tapwaves.tick();1415 leds.send(tapwaves.render(|x, y| rainbow_single(x, y, counter.get() as u8)));1617 counter.inc();1819 if (counter.get() % 128) == 0 {20 let _ = COMMAND_CHAN.try_send((21 DomToSub::ResyncLeds(counter.get()),22 Duration::from_millis(5),23 ));24 }2526 ticker.next().await;27 }28}1#[embassy::task]2async fn led_task(mut leds: Leds) {3 let fps = 30;4 let mut tapwaves = TapWaves::new();5 let mut ticker = Ticker::every(Duration::from_millis(1000 / fps));6 let mut counter = WrappingID::<u16>::new(0);78 loop {9 while let Ok(event) = LED_KEY_LISTEN_CHAN.try_recv() {10 tapwaves.update(event);11 }1213 tapwaves.tick();1415 leds.send(tapwaves.render(|x, y| rainbow_single(x, y, counter.get() as u8)));1617 counter.inc();1819 if (counter.get() % 128) == 0 {20 let _ = COMMAND_CHAN.try_send((21 DomToSub::ResyncLeds(counter.get()),22 Duration::from_millis(5),23 ));24 }2526 ticker.next().await;27 }28}I also added in animated waves that emanate from each key when pressed, I’m really making full use of the nRF’s FP unit here.
OLEDs
The Corne has support for a 128x32 display on each side, enough space that I’m struggling to decide what to put on each.
For the right side I have some metrics displayed: the total number of keypresses, the current keypresses per second, and the number of seconds the keyboard has been on. I also have a sliding display of keypresses at the bottom:
And for the left side I currently have a badly drawn bongo cat, I’m still thinking of what to use the remaining 96x32 pixels for.
To control the OLED displays, I use jamwaffles/ssd1306 which handles writing out a buffer the display, and embedded-graphics to draw text, images, and other geometry.
I use the following to initialize the display and handle turning the display off during periods of inactivity:
1type OledDisplay<'a, T> =2 Ssd1306<I2CInterface<Twim<'a, T>>, DisplaySize128x32, BufferedGraphicsMode<DisplaySize128x32>>;34pub struct Oled<'a, T: Instance> {5 status: bool,6 display: OledDisplay<'a, T>,7}89impl<'a, T: Instance> Oled<'a, T> {10 pub fn new(twim: Twim<'a, T>) -> Self {11 let i2c = I2CDisplayInterface::new(twim);12 let display = Ssd1306::new(i2c, DisplaySize128x32, DisplayRotation::Rotate0)13 .into_buffered_graphics_mode();14 Self {15 status: true,16 display,17 }18 }1920 pub async fn init(&mut self) -> Result<(), DisplayError> {21 self.display.set_rotation(DisplayRotation::Rotate90).await?;22 self.display.set_brightness(Brightness::BRIGHTEST).await?;23 self.display.init().await?;24 Ok(())25 }2627 // ...28}2930pub const OLED_TIMEOUT: Duration = Duration::from_secs(30);31static INTERACTED_EVENT: Event = Event::new();3233pub fn interacted() {34 INTERACTED_EVENT.set();35}3637async fn turn_off(oled: &Mutex<ThreadModeRawMutex, Oled<'_, impl Instance>>) {38 Timer::after(OLED_TIMEOUT).await;3940 let _ = oled.lock().await.set_off().await;4142 turn_on(oled).await;43}4445async fn turn_on(oled: &Mutex<ThreadModeRawMutex, Oled<'_, impl Instance>>) {46 INTERACTED_EVENT.wait().await;4748 let _ = oled.lock().await.set_on().await;49}5051pub async fn display_timeout_task<'a, T: Instance>(oled: &Mutex<ThreadModeRawMutex, Oled<'a, T>>)52where53 Twim<'a, T>: I2c<u8>,54{55 loop {56 select(turn_on(oled), turn_off(oled)).await;57 }58}1type OledDisplay<'a, T> =2 Ssd1306<I2CInterface<Twim<'a, T>>, DisplaySize128x32, BufferedGraphicsMode<DisplaySize128x32>>;34pub struct Oled<'a, T: Instance> {5 status: bool,6 display: OledDisplay<'a, T>,7}89impl<'a, T: Instance> Oled<'a, T> {10 pub fn new(twim: Twim<'a, T>) -> Self {11 let i2c = I2CDisplayInterface::new(twim);12 let display = Ssd1306::new(i2c, DisplaySize128x32, DisplayRotation::Rotate0)13 .into_buffered_graphics_mode();14 Self {15 status: true,16 display,17 }18 }1920 pub async fn init(&mut self) -> Result<(), DisplayError> {21 self.display.set_rotation(DisplayRotation::Rotate90).await?;22 self.display.set_brightness(Brightness::BRIGHTEST).await?;23 self.display.init().await?;24 Ok(())25 }2627 // ...28}2930pub const OLED_TIMEOUT: Duration = Duration::from_secs(30);31static INTERACTED_EVENT: Event = Event::new();3233pub fn interacted() {34 INTERACTED_EVENT.set();35}3637async fn turn_off(oled: &Mutex<ThreadModeRawMutex, Oled<'_, impl Instance>>) {38 Timer::after(OLED_TIMEOUT).await;3940 let _ = oled.lock().await.set_off().await;4142 turn_on(oled).await;43}4445async fn turn_on(oled: &Mutex<ThreadModeRawMutex, Oled<'_, impl Instance>>) {46 INTERACTED_EVENT.wait().await;4748 let _ = oled.lock().await.set_on().await;49}5051pub async fn display_timeout_task<'a, T: Instance>(oled: &Mutex<ThreadModeRawMutex, Oled<'a, T>>)52where53 Twim<'a, T>: I2c<u8>,54{55 loop {56 select(turn_on(oled), turn_off(oled)).await;57 }58}To then generate the content for the displays, I use the following (for the rhs):
1async fn render_normal(&mut self) {2 let character_style = MonoTextStyle::new(&PROFONT_9_POINT, BinaryColor::On);3 let textbox_style = TextBoxStyleBuilder::new()4 .height_mode(embedded_text::style::HeightMode::FitToText)5 .alignment(embedded_text::alignment::HorizontalAlignment::Justified)6 .paragraph_spacing(6)7 .build();89 let bounds = Rectangle::new(Point::zero(), Size::new(32, 0));1011 self.buf.clear();1213 let kp = TOTAL_KEYPRESSES.load(core::sync::atomic::Ordering::Relaxed);14 let cps = AVERAGE_KEYPRESSES.load(core::sync::atomic::Ordering::Relaxed);15 let cps = f32::trunc(cps * 10.0) / 10.0;16 let mut fp_buf = dtoa::Buffer::new();17 let cps = fp_buf.format_finite(cps);1819 let _ = uwriteln!(&mut self.buf, "kp:");20 let _ = uwriteln!(&mut self.buf, "{}", kp);21 let _ = uwriteln!(&mut self.buf, "cps:");22 let _ = uwriteln!(&mut self.buf, "{}/s", cps);23 let _ = uwriteln!(&mut self.buf, "tick:");24 let _ = uwriteln!(&mut self.buf, "{}", self.ticks);2526 let text_box =27 TextBox::with_textbox_style(&self.buf, bounds, character_style, textbox_style);2829 let lines = {30 let samples = self.sample_buffer.lock().await;31 samples32 .oldest_ordered()33 .enumerate()34 .map(|(idx, height)| {35 Line::new(36 Point::new(idx as i32, 128 - (*height as i32).clamp(0, 16)),37 Point::new(idx as i32, 128),38 )39 .into_styled(PrimitiveStyle::with_stroke(BinaryColor::On, 1))40 })41 .collect::<heapless::Vec<_, 32>>()42 };4344 let _ = self45 .oled46 .lock()47 .await48 .draw(move |d| {49 let _ = text_box.draw(d);50 for line in lines {51 let _ = line.draw(d);52 }53 })54 .await;55}1async fn render_normal(&mut self) {2 let character_style = MonoTextStyle::new(&PROFONT_9_POINT, BinaryColor::On);3 let textbox_style = TextBoxStyleBuilder::new()4 .height_mode(embedded_text::style::HeightMode::FitToText)5 .alignment(embedded_text::alignment::HorizontalAlignment::Justified)6 .paragraph_spacing(6)7 .build();89 let bounds = Rectangle::new(Point::zero(), Size::new(32, 0));1011 self.buf.clear();1213 let kp = TOTAL_KEYPRESSES.load(core::sync::atomic::Ordering::Relaxed);14 let cps = AVERAGE_KEYPRESSES.load(core::sync::atomic::Ordering::Relaxed);15 let cps = f32::trunc(cps * 10.0) / 10.0;16 let mut fp_buf = dtoa::Buffer::new();17 let cps = fp_buf.format_finite(cps);1819 let _ = uwriteln!(&mut self.buf, "kp:");20 let _ = uwriteln!(&mut self.buf, "{}", kp);21 let _ = uwriteln!(&mut self.buf, "cps:");22 let _ = uwriteln!(&mut self.buf, "{}/s", cps);23 let _ = uwriteln!(&mut self.buf, "tick:");24 let _ = uwriteln!(&mut self.buf, "{}", self.ticks);2526 let text_box =27 TextBox::with_textbox_style(&self.buf, bounds, character_style, textbox_style);2829 let lines = {30 let samples = self.sample_buffer.lock().await;31 samples32 .oldest_ordered()33 .enumerate()34 .map(|(idx, height)| {35 Line::new(36 Point::new(idx as i32, 128 - (*height as i32).clamp(0, 16)),37 Point::new(idx as i32, 128),38 )39 .into_styled(PrimitiveStyle::with_stroke(BinaryColor::On, 1))40 })41 .collect::<heapless::Vec<_, 32>>()42 };4344 let _ = self45 .oled46 .lock()47 .await48 .draw(move |d| {49 let _ = text_box.draw(d);50 for line in lines {51 let _ = line.draw(d);52 }53 })54 .await;55}Inter-board communication
Since I’m not using Bluetooth, the right side of the split needs to communicate with the left side, there are a few ways to do this but I went with a UART as it easily allows both sides to send messages to the other.
However a UART has no provisions for error checking (outside of a parity bit) or framing so I have to do that myself.
To handle encoding and decoding of messages I use jamesmunns/postcard which conveniently also handles framing and failure recovery through the use of COBS
To ensure message delivery a uuid and checksum is attached to each command, when one side receives a message and validates the checksum, an Ack message with the same uuid is sent back to the other side. After a command is sent, the keyboard waits a period of time for an Ack before considering the message to have not been received. This period is variable depending on the message sent, keypress events have a longer timeout as duplicated keypresses aren’t a good thing, messages that can tolerate duplication are sent with a lower timeout to decrease latency.
The messages sent between sides are defined as plain rust enums that derive serde::Serialize and serde::Deserialize:
1#[derive(Serialize, Deserialize, Eq, PartialEq, Format, Hash, Clone)]2pub enum DomToSub {3 ResyncLeds(u16),4 Reset,5 SyncKeypresses(u16),6 WritePixels {7 row: u8,8 data_0: [u8; 4],9 data_1: [u8; 4],10 },11}1213#[derive(Serialize, Deserialize, Eq, PartialEq, Debug, Format, Hash, Clone)]14pub enum SubToDom {15 KeyPressed(u8),16 KeyReleased(u8),17}1#[derive(Serialize, Deserialize, Eq, PartialEq, Format, Hash, Clone)]2pub enum DomToSub {3 ResyncLeds(u16),4 Reset,5 SyncKeypresses(u16),6 WritePixels {7 row: u8,8 data_0: [u8; 4],9 data_1: [u8; 4],10 },11}1213#[derive(Serialize, Deserialize, Eq, PartialEq, Debug, Format, Hash, Clone)]14pub enum SubToDom {15 KeyPressed(u8),16 KeyReleased(u8),17}These messages are then wrapped in the structs defined here:
1#[derive(Serialize, Deserialize, defmt::Format, Debug)]2pub struct Command<T> {3 pub uuid: u8,4 pub csum: u8,5 pub cmd: T,6}78pub fn csum<T: Hash>(v: T) -> u8 {9 let mut hasher = StableHasher::new(fnv::FnvHasher::default());10 v.hash(&mut hasher);11 let checksum = hasher.finish();1213 let bytes = checksum.to_le_bytes();1415 bytes.iter().fold(0, core::ops::BitXor::bitxor)16}1718impl<T: Hash> Command<T> {19 pub fn new(cmd: T) -> Self {20 static UUID_GEN: AtomicU8 = AtomicU8::new(0);21 let uuid = UUID_GEN.fetch_add(1, core::sync::atomic::Ordering::SeqCst);22 let csum = csum((&cmd, uuid));23 Self { uuid, csum, cmd }24 }2526 /// validate the data of the command27 pub fn validate(&self) -> bool {28 let csum = csum((&self.cmd, self.uuid));29 csum == self.csum30 }3132 pub fn ack(&self) -> Ack {33 let csum = csum(self.uuid);34 Ack {35 uuid: self.uuid,36 csum,37 }38 }39}4041#[derive(Serialize, Deserialize, defmt::Format, Debug)]42pub struct Ack {43 pub uuid: u8,44 pub csum: u8,45}4647#[derive(Serialize, Deserialize, defmt::Format, Debug)]48#[repr(u8)]49pub enum CmdOrAck<T> {50 Cmd(Command<T>),51 Ack(Ack),52}5354impl Ack {55 pub fn validate(self) -> Option<Self> {56 let csum = csum(self.uuid);57 if csum == self.csum {58 Some(self)59 } else {60 None61 }62 }63}1#[derive(Serialize, Deserialize, defmt::Format, Debug)]2pub struct Command<T> {3 pub uuid: u8,4 pub csum: u8,5 pub cmd: T,6}78pub fn csum<T: Hash>(v: T) -> u8 {9 let mut hasher = StableHasher::new(fnv::FnvHasher::default());10 v.hash(&mut hasher);11 let checksum = hasher.finish();1213 let bytes = checksum.to_le_bytes();1415 bytes.iter().fold(0, core::ops::BitXor::bitxor)16}1718impl<T: Hash> Command<T> {19 pub fn new(cmd: T) -> Self {20 static UUID_GEN: AtomicU8 = AtomicU8::new(0);21 let uuid = UUID_GEN.fetch_add(1, core::sync::atomic::Ordering::SeqCst);22 let csum = csum((&cmd, uuid));23 Self { uuid, csum, cmd }24 }2526 /// validate the data of the command27 pub fn validate(&self) -> bool {28 let csum = csum((&self.cmd, self.uuid));29 csum == self.csum30 }3132 pub fn ack(&self) -> Ack {33 let csum = csum(self.uuid);34 Ack {35 uuid: self.uuid,36 csum,37 }38 }39}4041#[derive(Serialize, Deserialize, defmt::Format, Debug)]42pub struct Ack {43 pub uuid: u8,44 pub csum: u8,45}4647#[derive(Serialize, Deserialize, defmt::Format, Debug)]48#[repr(u8)]49pub enum CmdOrAck<T> {50 Cmd(Command<T>),51 Ack(Ack),52}5354impl Ack {55 pub fn validate(self) -> Option<Self> {56 let csum = csum(self.uuid);57 if csum == self.csum {58 Some(self)59 } else {60 None61 }62 }63}And then are serialized with postcard and transmitted to the other side:
1async fn task(self) {2 loop {3 let val = self.mix_chan.recv().await;45 let mut buf = [0u8; BUF_SIZE];6 if let Ok(buf) =7 postcard::serialize_with_flavor(&val, Cobs::try_new(Slice::new(&mut buf)).unwrap())8 {9 let r = self.tx.write(buf).await;10 debug!("Transmitted {:?}, r: {:?}", val, r);11 }12 }13}1async fn task(self) {2 loop {3 let val = self.mix_chan.recv().await;45 let mut buf = [0u8; BUF_SIZE];6 if let Ok(buf) =7 postcard::serialize_with_flavor(&val, Cobs::try_new(Slice::new(&mut buf)).unwrap())8 {9 let r = self.tx.write(buf).await;10 debug!("Transmitted {:?}, r: {:?}", val, r);11 }12 }13}Other dumb things
Okay so I have a keyboard running firmware on rust, oh and I can also talk to it over USB serial in the same way each half talks to the other. What can I do?
Metrics
With a little bit of code on the keyboard we can have it reply with the keypress counter when queried:
1#[embassy::task]2async fn usb_serial_task(mut class: CdcAcmClass<'static, UsbDriver>) {3 loop {4 let in_chan: &mut Channel<ThreadModeRawMutex, u8, 128> = forever!(Channel::new());5 let out_chan: &mut Channel<ThreadModeRawMutex, u8, 128> = forever!(Channel::new());6 let msg_out_chan: &mut Channel<ThreadModeRawMutex, HostToKeyboard, 16> =7 forever!(Channel::new());8 let msg_in_chan: &mut Channel<ThreadModeRawMutex, (KeyboardToHost, Duration), 16> =9 forever!(Channel::new());10 class.wait_connection().await;11 let mut wrapper = UsbSerialWrapper::new(&mut class, &*in_chan, &*out_chan);12 let mut eventer = Eventer::new(&*in_chan, &*out_chan, msg_out_chan.sender());1314 let handle = async {15 loop {16 match msg_out_chan.recv().await {17 HostToKeyboard::RequestStats => {18 msg_in_chan19 .send((20 KeyboardToHost::Stats {21 keypresses: TOTAL_KEYPRESSES22 .load(core::sync::atomic::Ordering::Relaxed),23 },24 Duration::from_millis(5),25 ))26 .await;27 },28 // ...29 }30 }31 };3233 let (e_a, e_b, e_c) = eventer.split_tasks(msg_in_chan);3435 select3(wrapper.run(), select3(e_a, e_b, e_c), handle).await;36 }37}1#[embassy::task]2async fn usb_serial_task(mut class: CdcAcmClass<'static, UsbDriver>) {3 loop {4 let in_chan: &mut Channel<ThreadModeRawMutex, u8, 128> = forever!(Channel::new());5 let out_chan: &mut Channel<ThreadModeRawMutex, u8, 128> = forever!(Channel::new());6 let msg_out_chan: &mut Channel<ThreadModeRawMutex, HostToKeyboard, 16> =7 forever!(Channel::new());8 let msg_in_chan: &mut Channel<ThreadModeRawMutex, (KeyboardToHost, Duration), 16> =9 forever!(Channel::new());10 class.wait_connection().await;11 let mut wrapper = UsbSerialWrapper::new(&mut class, &*in_chan, &*out_chan);12 let mut eventer = Eventer::new(&*in_chan, &*out_chan, msg_out_chan.sender());1314 let handle = async {15 loop {16 match msg_out_chan.recv().await {17 HostToKeyboard::RequestStats => {18 msg_in_chan19 .send((20 KeyboardToHost::Stats {21 keypresses: TOTAL_KEYPRESSES22 .load(core::sync::atomic::Ordering::Relaxed),23 },24 Duration::from_millis(5),25 ))26 .await;27 },28 // ...29 }30 }31 };3233 let (e_a, e_b, e_c) = eventer.split_tasks(msg_in_chan);3435 select3(wrapper.run(), select3(e_a, e_b, e_c), handle).await;36 }37}And then with the help of another rust program to periodically request the number of keys pressed from the keyboard (I could do this by keylogging, but that’s not as fun) and export the count to Prometheus, we get a fancy dashboard:
Video playback
Since the nRF52840 is pretty powerful, we can get away with streaming a video to the displays of the keyboard. The left side handles receiving frames from the computer over USB serial, and then sends the frame to its OLED task if the packet is for the LHS, or forwarded to the RHS otherwise.
The OLED tasks on each side have a channel to receive frames from, when a frame is received the task stops rendering the original content for a second and instead displays the received frame.
The result is this:
Links
If you’re interested, you can find the source code here