Oxidizing Arduino

#rust#arduino#display::alemi::2022-10-16 02:36

I wanted to build myself a small pc monitor, something simple with an oled display driven by an arduino. I'm not very fond of the Arduino IDE, and some quick research shows that Rust can compile for AVR.

Since I really like Rust, this seemed like a perfect small project to take on, with much to learn in the making.

Setting up

I am building on Linux, specifically Arch, so your mileage might vary on different platforms.

Make sure you have rust and cargo installed. We also need the nightly toolchain to build for avr (as of October 2022).

$ rustup override set nightly    # if you use rustup

Environment

We can now build for the avr mcu, but directly accessing memory offsets to interact with the hardware is not really user-friendly. Rahix/avr-hal and arduino-hal make our life simpler with readily available and safe APIs. There's a project template available, but let's create one from scratch.

We need to instruct Cargo about our specific microcontroller architecture. Grab the correct spec (a .json file) from here and put it somewhere in your project folder (in my case, under spec/ folder). Then create .cargo/config.toml, referencing your board spec:

## .cargo/config.toml
[build]
target = "spec/avr-atmega328p.json"
runner = "" # remember this, we will use it later

[unstable]
build-std = ["core"]

Note that we also need to compile the core standard library ourselves, which is an unstable feature.

At this point we can prepare our Cargo.toml and main.rs files. avr-hal has many examples we can build on. We need to specify a panic handler (which is to halt in our case) and to include an arduino-hal feature for our board directly from git. Then we also need to add parameters to build profiles, to make builds succeed and to minimize output size.

## Cargo.toml
[package]
name = "oxiduino" # give it a cool name!
version = "0.1.0"
edition = "2021"

[dependencies]
panic-halt = "0.2.0"
embedded-hal = "0.2.3"

# not on crates.io so we fetch it via github
[dependencies.arduino-hal]
git = "https://github.com/rahix/avr-hal"
rev = "1aacefb335517f85d0de858231e11055d9768cdf" # check for latest!
features = ["arduino-nano"] # specify your board as a feature here

# minimal config to be able to build
[profile.dev]
panic = "abort"
opt-level = "s"

# optimize for minimal size, which will be important
[profile.release]
panic = "abort"
codegen-units = 1
lto = true
opt-level = "s"

The "hello world" code on every arduino is blinking the integrated LED:

//// src/main.rs
#![no_std]
#![no_main]

use panic_halt as _;

#[arduino_hal::entry]
fn main() -> ! {
	// remember these 2, we will use them a lot
	let dp = arduino_hal::Peripherals::take().unwrap();
	let pins = arduino_hal::pins!(dp);
	// enable pin D13 as output and set it high
	let mut led = pins.d13.into_output();
	led.set_high();
	// blink!
	loop {
		led.toggle();
		arduino_hal::delay_ms(500);
	}
}

With this set up, running cargo build should produce a oxiduino.elf inside target/<board-type>/debug/ (in my case, board type is avr-atmega328p).

Flashing code

Once we have a .elf ready, we can flash it using avrdude (should be available from your package manager). After connecting our arduino, we should check which serial port it gets assigned (under /dev, for me ttyUSB0). We need to specify avr device (-p), programmer (-c) and port (-P) to use. We can add -D to skip erasing first. Then we need to provide our operation (-U) with format <memtype>:r|w:<fname>:<format>.

$ avrdude -p atmega328p -c arduino -P /dev/ttyUSB0 -D -U "flash:w:oxiduino.elf:e"

When this finishes, our arduino should start blinking.

Manually running avrdude or using a custom script is not super comfortable. We can add ravedude to flash and connect a serial interface when calling $ cargo run. Just install ravedude ($ cargo install ravedude) and then put it in your path. We can then add ravedude as a runner under our .cargo/config.toml:

[build]
runner = "ravedude nano --open-console --baudrate 115200 -P /dev/ttyUSB0"

Invoking $ cargo run should now automatically flash our code and leave us with a ready to use serial console and an arduino blinking its led!

Dimming the LED

A blinking led is cool, but it's only either on or off. If you have any spare led and a small resistance for it (~1k should do), we can make our arduino dim it.

The ATMega328P has 3 timers, capable of driving 6 outputs. By manipulating the correct register, we can set the duty cycle of each PWM-enabled output, and arduino-hal provides us wrappers to do it easily.

Notice that, depending on which pin you plan to use, the timer needed to initialize it will change. This respects the ATMega hardware: some pins are connected to some timers, and not all. Rust will enforce us to use the correct ones at compile time.

	let timer1 = Timer1Pwm::new(dp.TC1, Prescaler::Direct);
	let mut led = pins.d10.into_output().into_pwm(&timer1);
	led.enable(); // must call this once to enable PWM timer control
	led.set_duty(0); // from 0 to 255

Interacting

Our project will need to interact with external devices, like almost any project. We will need to send data from our PC to the arduino, which will have to communicate with the display IC.

Reading data from serial interface

We can read serial data coming from our PC with arduino's USART. arduino-hal has a macro to let us easily create a serial connection. Pay attention when setting your baudrate: it must be the same used in .cargo/config.toml and in the (soon to come) python script. Notice also that we need to bring in scope the Read trait.

use embedded_hal::serial::Read;
// ...
	let mut serial = arduino_hal::default_serial!(dp, pins, 115200);
	match serial.read() {
		Ok(byte) => { }, // pop a byte
		Err(_) => { },   // nothing to read
	}

Since there is no such thing as an Operating System to block us until data is available to read, most blocking implementations will just busy wait serial.read() until data is available. While this can make code more readable, I will want to retain the ability to process inputs while no data is available, and thus I'll invoke serial.read() directly.

Sending data via serial

We will need to send data to our arduino. A super simple driver could be made with a simple python script:

# pip install pyserial psutil
import serial, struct, psutil
port = serial.Serial("/dev/ttyUSB0", 57600) # device, baudrate
while True:
	utilization = psutil.cpu_percent(0.5) # blocks and record cpu utilization %
	serial.write(struct.pack("B", int((utilization * 255 ) / 100)))

As long as wait times are long and data is little, even such a barebones solution might work. As we start to send more data to our arduino more frequently, a more structured approach will be needed. I built a very rough "packet system" for my PC monitor, I may go in more depth in a future post.

A super simple CPU monitor

Combining what we know so far, we can update the duty cycle of a PWM pin which drives a LED every time we receive data from serial. First we should compile and flash our code:

#![no_std]
#![no_main]

use panic_halt as _;

use embedded_hal::serial::Read;
use arduino_hal::simple_pwm::{IntoPwmPin, Prescaler, Timer1Pwm};

#[arduino_hal::entry]
fn main() -> ! {
	let dp = arduino_hal::Peripherals::take().unwrap();
	let pins = arduino_hal::pins!(dp);
	let timer1 = Timer1Pwm::new(dp.TC1, Prescaler::Direct);
	let mut led = pins.d10.into_output().into_pwm(&timer1);
	let mut serial = arduino_hal::default_serial!(dp, pins, 115200);

	led.enable();
	led.set_duty(0);

	loop {
		match serial.read() {
			Ok(byte) => led.set_duty(byte),
			Err(_) => { },
		}
	}
}

Then we should stop the cargo serial interface (because device usage is exclusive) and run our python script.

Our led should then start to light up and change. We can make sure that it's tied to CPU usage by running a quick $ stress --cpu 3. This is extremely rudimentary but still a hardware CPU usage monitor! We can improve it by adding a led for each cpu core and by making it update way faster (warning: flashes a lot!).

Display

While playing with leds is fun, it only gives a general idea of the resources usage of my system. I want the better resolution of a small OLED display.

Most displays will connect either with I2C or SPI, so make sure which one you'll be using and which pins you should connect your display to. The display I'm using connects via I2C. On the Nano, SDA is on A4 and SCL on A5, but you should find these informations easily for any board.

Controlling the display

You can then send commands to your display directly, usually there's a datasheet explaining what commands are available. Many display boards have libraries already implemented to draw on the display. In my case, I could use jamwaffles/ssd1306.

	let i2c = arduino_hal::i2c::I2c::new(
		dp.TWI, pins.a4.into_pull_up_input(), pins.a5.into_pull_up_input(), 800000
	);
	let interface = ssd1306::I2CDisplayInterface::new(i2c);
	let mut display = ssd1306::Ssd1306::new(
		interface,
		ssd1306::size::DisplaySize128x64,
		ssd1306::rotation::DisplayRotation::Rotate0
	).into_buffered_graphics_mode();

Embedded Graphics

Drawing is pretty complicated in such a limited context. Even a single rectangle requires either some cycles or a pre-calculated map. To overcome this we can use embedded-graphics: a rust crate providing optimized and abstracted drawing primitives, like shapes and text. Our SSD1306 library provides a draw target compatible with embedded graphics.

	display.init().unwrap();

	let style = PrimitiveStyleBuilder::new()
		.stroke_color(BinaryColor::On)
		.stroke_width(1)
		.build();

	Rectangle::new(Point::new(10, 10), Size::new(50, 20))
		.into_styled(style)
		.draw(&mut display)
		.unwrap();

	Text::new(
		"hello!",
		Point::new(20, 20),
		MonoTextStyle::new(&FONT_4X6, BinaryColor::On)
	).draw(&mut display).unwrap();

	display.flush().unwrap();

Adding Embedded Graphics to our project greatly increased the final .elf size, which may bring us over the memory limit for an arduino. Developing on the arduino requires special care to resulting size: even just a floating point operation throws in some thousands bytes!

Wrapping up

With avr-hal/arduino-hal, ssd1306 and embedded-graphics we have all the necessary building blocks to create a nice hardware monitor. From python we can easily get data with psutil and send data with pyserial. Receiving data on our arduino requires some more care (alignment becomes an issue with higher speeds), but thanks to embedded-graphics and arduino-hal helpers we can easily display it on Peripherals, such a ssd1306 display.

The post picture is what I built, still without a case and on a breadboard. Its source code can be seen here.