USB

For now only USB serial is supported. Eventually, the idea would be for applets to describe the USB interfaces they need in some init function. The scheduler would then create the USB device based on those information. And only then start the applets with capabilities to the interfaces they asked for.

In this section, we will illustrate USB serial usage by walking through the memory_game example. The game is essentially an infinite loop of memory questions. The player has 3 seconds to memorize a random base32 string (the length is the current level in the game and thus represents the difficulty). The player than has 7 seconds to type it back. On success they go to the next level, otherwise to the previous level.

The applet has only 2 states across loop iterations:

  • The level of the game (and thus the length of the string to remember) starting at 3.
  • The next prompt to show to the player while they get ready for the next question.
    let mut level = 3; // length of the string to remember
    let mut prompt = "Press ENTER when you are ready.";

Everything else is in the infinite loop:

    loop {

First thing we do is print the prompt and wait for the player to press Enter. We use ANSI escape codes to overwrite whatever was there before. As an invariant throughout the game, we always use a single line of the terminal. This is particularly important to overwrite the question since the player has to guess it. We write to the USB serial using serial::write_all(). This function is generic over objects implementing serial::Serial, in this case usb::serial::UsbSerial.

        serial::write_all(&UsbSerial, format!("\r\x1b[K{prompt}").as_bytes()).unwrap();

We then wait until the player press Enter. We can read a single byte from the USB serial using serial::read_byte(). The terminal sends 0x0d when Enter is pressed.

        // Make sure the player is ready.
        while serial::read_byte(&UsbSerial).unwrap() != 0x0d {}

To generate the next question, we use rng::fill_bytes() which fills a buffer with random bytes. We provide a buffer with the length of the current level. For the string to be printable we truncate the entropy of each byte from 8 to 5 bits and convert it to a base32 symbol.

        // Generate a question for this level.
        let mut question = vec![0; level];
        rng::fill_bytes(&mut question).unwrap();
        for byte in &mut question {
            const BASE32: [u8; 32] = *b"ABCDEFGHIJKLMNOPQRSTUVWXYZ234567";
            *byte = BASE32[(*byte & 0x1f) as usize];
        }
        let mut question = String::from_utf8(question).unwrap();

We can now show the question to the player. We do so using a process helper function that we will also use for the answer. We instantiate this function such that the player has 3 seconds to memorize the question and may hit Enter at any time to start answering.

        // Display the question.
        process(3, "Memorize this", &mut question, |_, x| x == 0x0d);

After 3 seconds have elapsed or if the player hit Enter, we read the answer from the player. We give them 7 seconds to type the answer. We also convert lower-case letters to upper-case for convenience (it's easier to read upper-case but easier to type lower-case). We also support backspace which the terminal sends as 0x7f. And same as for the question, we let the player exit early with Enter to avoid waiting until the timeout.

        // Read the answer.
        let mut answer = String::new();
        process(7, "Type what you remember", &mut answer, |answer, byte| {
            match byte {
                b'A' ..= b'Z' | b'2' ..= b'7' => answer.push(byte as char),
                b'a' ..= b'z' => answer.push(byte.to_ascii_uppercase() as char),
                0x7f => drop(answer.pop()),
                0x0d => return true,
                _ => (),
            }
            false
        });

Once we have the answer, we check if it matches the question. If it does, we promote the player to the next level. If it doesn't, we demote the player to the previous level. However, if there are no previous level because the player is at level 1, then we let them retry the level to show our support. We use ANSI escape codes to highlight the result.

        // Check the answer.
        if answer == question {
            level += 1;
            prompt = "\x1b[1;32mPromotion!\x1b[m Press ENTER for next level.";
        } else if level > 1 {
            level -= 1;
            prompt = "\x1b[1;31mDemotion...\x1b[m Press ENTER for previous level.";
        } else {
            prompt = "\x1b[1;41mRetry?\x1b[m Press ENTER to retry.";
        }

Now that we're done with the main loop, let's look at the process helper. It takes 4 arguments:

  • max_secs: usize: the maximum display time in seconds.
  • prompt: &str: the message shown at the beginning of the line.
  • data: &mut String: the data shown after the prompt, which may be updated (see below).
  • update: impl Fn(&mut String, u8) -> bool: the closure called on each input byte possibly updating the data and returning whether processing should end immediately without waiting for the maximum display time.
fn process(
    max_secs: usize, prompt: &str, data: &mut String, update: impl Fn(&mut String, u8) -> bool,
) {

The helper counts the number of elapsed seconds in shared variable secs and updates it using a periodic timer every second.

    let secs = Rc::new(Cell::new(0));
    let timer = timer::Timer::new({
        let time = secs.clone();
        move || time.set(time.get() + 1)
    });
    timer.start(timer::Periodic, Duration::from_secs(1));

The helper loops as long as the update function didn't say to stop (tracked by the done variable) and there is still time available.

    let mut done = false;
    while !done && secs.get() < max_secs {

We update the line in the terminal with the prompt, time left, and current data. We use ANSI escape codes to highlight the data and help readability.

        let secs = max_secs - secs.get();
        let message = format!("\r\x1b[K{prompt} ({secs} seconds remaining): \x1b[1m{data}\x1b[m");
        serial::write_all(&UsbSerial, message.as_bytes()).unwrap();

To be able to update the time left in the terminal we must read from the USB serial asynchronously using serial::Reader. We create a reader by providing a mutable buffer to which the reader will write the received bytes.

        let mut buffer = [0; 8];
        let reader = serial::Reader::new(&UsbSerial, &mut buffer);

We then sleep until a callback is executed using scheduling::wait_for_callback(). This callback may either be the timer firing the next second or the reader getting input from the USB serial.

        scheduling::wait_for_callback();

We call Reader::result() to know how many bytes were read from USB serial and written to the buffer (or if an error occurred). We then simply iterate over the received bytes and update the data and early exit status according to the provided closure. Same as with timers, when a reader is dropped, its callback is canceled.

        let len = reader.result().unwrap();
        for &byte in &buffer[.. len] {
            done |= update(data, byte);
        }

The final code looks like this:

#![no_std]
wasefire::applet!();

use alloc::rc::Rc;
use alloc::string::String;
use alloc::{format, vec};
use core::cell::Cell;
use core::time::Duration;

use wasefire::usb::serial::UsbSerial;

fn main() {
    let mut level = 3; // length of the string to remember
    let mut prompt = "Press ENTER when you are ready.";
    loop {
        serial::write_all(&UsbSerial, format!("\r\x1b[K{prompt}").as_bytes()).unwrap();

        // Make sure the player is ready.
        while serial::read_byte(&UsbSerial).unwrap() != 0x0d {}

        // Generate a question for this level.
        let mut question = vec![0; level];
        rng::fill_bytes(&mut question).unwrap();
        for byte in &mut question {
            const BASE32: [u8; 32] = *b"ABCDEFGHIJKLMNOPQRSTUVWXYZ234567";
            *byte = BASE32[(*byte & 0x1f) as usize];
        }
        let mut question = String::from_utf8(question).unwrap();

        // Display the question.
        process(3, "Memorize this", &mut question, |_, x| x == 0x0d);

        // Read the answer.
        let mut answer = String::new();
        process(7, "Type what you remember", &mut answer, |answer, byte| {
            match byte {
                b'A' ..= b'Z' | b'2' ..= b'7' => answer.push(byte as char),
                b'a' ..= b'z' => answer.push(byte.to_ascii_uppercase() as char),
                0x7f => drop(answer.pop()),
                0x0d => return true,
                _ => (),
            }
            false
        });

        // Check the answer.
        if answer == question {
            level += 1;
            prompt = "\x1b[1;32mPromotion!\x1b[m Press ENTER for next level.";
        } else if level > 1 {
            level -= 1;
            prompt = "\x1b[1;31mDemotion...\x1b[m Press ENTER for previous level.";
        } else {
            prompt = "\x1b[1;41mRetry?\x1b[m Press ENTER to retry.";
        }
    }
}

fn process(
    max_secs: usize, prompt: &str, data: &mut String, update: impl Fn(&mut String, u8) -> bool,
) {
    let secs = Rc::new(Cell::new(0));
    let timer = timer::Timer::new({
        let time = secs.clone();
        move || time.set(time.get() + 1)
    });
    timer.start(timer::Periodic, Duration::from_secs(1));
    let mut done = false;
    while !done && secs.get() < max_secs {
        let secs = max_secs - secs.get();
        let message = format!("\r\x1b[K{prompt} ({secs} seconds remaining): \x1b[1m{data}\x1b[m");
        serial::write_all(&UsbSerial, message.as_bytes()).unwrap();
        let mut buffer = [0; 8];
        let reader = serial::Reader::new(&UsbSerial, &mut buffer);
        scheduling::wait_for_callback();
        let len = reader.result().unwrap();
        for &byte in &buffer[.. len] {
            done |= update(data, byte);
        }
    }
}

You can connect to the USB serial with the following command:

picocom -q /dev/ttyACM1