LCD Menu: The Hardware Setup
Pretty much right away when I started programming the Pico I wanted to know more about what was going on inside it during the program run-time. It gets old very quickly to have only the feedback of the onboard LED to know what’s going on in the program.
TL;DR:
This post mostly focuses on the utilization of I2C, working with the low memory no_std environment, and getting the SSD1306 LCD to show a “Hello, World!” state. Future posts will cover in more detail how to design UI interfacing, and code the menu.
My approach:
Get the device that holds the menu working
Design how a menu should interface with a program
Design how the data of the menu should be structured
Figure out how to unit test no_std code
Program the menu
So let’s move on with it!
OLED LCD Screens
I immediately popped on Amazon and bought a 4-pack of the I2C OLED 0.96 Inch OLED Display Module IIC SSD1306 128 64 LCD (not an affiliate link) screens. I didn’t even give it a second thought, or even bother comparing the pros and cons of I2C vs SPI vs UART.
I think it was ~$17 for 4 at the time of purchase. I was not really aware of how tiny these are. They are incredibly small. Smaller than 1 inch, so not much real-estate. But honestly, that’s a nice constraint for me for now, because I want to reduce as much complexity as possible starting out.
I’ve got the screen; Now what?
The finished code for the menu is available in my PicoPonics repo. But my first steps were not actually showing the entire menu, but simply showing some letters, as I had never written any code for the I2C (Eye-squared-See) protocol before.
Most of the time when I’m approaching something new I try to break everything down into the smallest simple pieces, even if those pieces aren’t reusable. There are two benefits to this that I notice almost every time I do it:
I learn more about what it is I’m actually doing, and what does and does not work.
I get more frequent ‘wins’ which keeps me alive for longer on the project. Too long without a win and I get a little or entirely deflated.
Getting a “Hello, World!” on the LCD Screen
Below is the critical code for working with the LCD screen via I2C.
// Set the pins to their default state
let pins = hal::gpio::Pins::new(
pac.IO_BANK0,
pac.PADS_BANK0,
sio.gpio_bank0,
&mut pac.RESETS,
);
// Configure two pins as being I²C, not GPIO
let scl_pin = pins.gpio3.into_mode::<hal::gpio::FunctionI2C>();
let sda_pin = pins.gpio2.into_mode::<hal::gpio::FunctionI2C>();
// Create the I²C drive, using the two pre-configured pins. This will fail
// at compile time if the pins are in the wrong mode, or if this I²C
// peripheral isn't available on these pins!
let i2c = hal::I2C::i2c1(
pac.I2C1,
sda_pin,
scl_pin,
400_u32.kHz(),
&mut pac.RESETS,
&clocks.system_clock,
);
// Prep LCD Screen interfacing
let interface = I2CDisplayInterface::new(i2c);
let mut display = Ssd1306::new(
interface,
DisplaySize128x64,
DisplayRotation::Rotate0,
).into_terminal_mode();
// Init and clear the screen
display.init().unwrap();
display.clear().unwrap();
// Create message to display
let mut message: [u8; 13] = [‘H’, ‘e’, ‘l’, ‘l’, ‘o’, ’,’, ‘ ‘, ‘W’, ‘o’, ‘r’, ‘l’, ‘d’, ‘!’];
// Actually send a message to the LCD display
for c in message {
let _ = display.write_str(
unsafe {
core::str::from_utf8_unchecked(&[c])
}
);
}
This is a lot! but that’s OK, let’s go over piece by piece to get a deeper understanding.
let pins = …
I’ve covered this in a previous post, but this line is preparing us a nice variable to reference when we need to interface with the registers that control the GPIO pins on the Pico.
let scl_pin = pins.gpio3.into_mode::<hal::gpio::FunctionI2C>();
let sda_pin = pins.gpio2.into_mode::<hal::gpio::FunctionI2C>();
SCL and SDA are the two communications lines in an I2C interface. Texas Instruments has this doc that explains in great detail what is going on with I2C.
SCL stands for Serial Clock
SDA stands for Serial Data
let i2c = hal::I2C::i2c1( … );
This variable is where we are defining low-level traits that define how the device will carry out the I2C comms. The resulting variable ‘i2c’ is a sort of configuration variable that provides the hardware with information on how to behave.
If you look back at the variable declaration, the second and third parameters are the two pins we just defined for SCL, and SDA.
let i2c = hal::I2C::i2c1(
pac.I2C1,
sda_pin,
scl_pin,
400_u32.kHz(),
&mut pac.RESETS,
&clocks.system_clock,
);
Below that you can see that we have 400_u32.khz(), which to me is really interesting because the ‘fast’ mode of I2C is set up to be 400Kbps. So assuming we are publishing 1 Byte per cycle, 400KHZ puts us on the fast mode configuration. I’ve not tested this theory so I could be wrong, but to me the correlation there seems a bit too coincidental.
let interface = I2CDisplayInterface::new(i2c);
let mut display = Ssd1306::new(
interface,
DisplaySize128x64,
DisplayRotation::Rotate0,
).into_terminal_mode();
This code is giving us a display interface, by loading our previous ‘i2c’ variable in. So the variable called interface in the above code, is now a data structure that is saying, “I am an LCD Display Interface, and I am using the I2C configuration from the variable called i2c.”
Nice.
The display variable uses that interface we just defined, and does some configuration that appears to be formatting to handle the data we are planning to publish. The .into_terminal_mode() method is interesting because it seems to be prepping the screen to handle characters as if it were a pure terminal, and I can verify this is exactly what happens. Characters appear from left to right and wrap precisely where they are supposed to.
Message variable and the For Loop
The most Rust-specific part, is where we create an array of chars, and iterate through them. This code:
let _ = display.write_str(
unsafe {
core::str::from_utf8_unchecked(&[c])
}
);
This is the first time I used unsafe code! And ultimately that is being utilized by the code that is triggering communication to the LCD screen. This is not where the magic happens, but this is where the magic is invoked. display.write_str(…) is passing our chars to the display.
Conclusion
Here is what was accomplished in this post:
Specified an LCD screen to display our menu
Learned a bit about I2C communication
Configured the Pins of the PI to use I2C
Create an I2C and Display configuration that is compatible with our LCD screen
Sent characters to be displayed on the screen
This is quite a lot of work! But it’s only the beginning of creating our custom menu selections. In the next post, we’ll cover a programming design pattern using Rust. The post will cover how to design and define an interface from our screen (UI in general) to the program as it’s running, so that clean updates to program parameters can be carried out.