How to use a 0.96 inch OLED with CircuitPython?

By admin
You connect a 0.96 inch OLED display to a microcontroller running CircuitPython by wiring the I2C pins (SDA and SCL) to your board, installing the necessary Adafruit libraries, and running a few lines of code to initialize the display and draw text or shapes. The specific model we are talking about is the 0.96 inch 128x64 i2c oled display, which uses the SSD1306 driver chip. This display is monochrome, has a resolution of 128 by 64 pixels, and communicates over I2C at a default address of 0x3C. It draws about 20mA when fully lit, which is low enough to run directly from a 3.3V logic pin on most boards like the Raspberry Pi Pico, ESP32-S3, or Adafruit QT Py. You do not need level shifters if your microcontroller runs at 3.3V, but if you use a 5V board like an Arduino Uno, you must use a logic level converter because the OLED is not 5V tolerant.

Hardware wiring details
The OLED module usually comes with four pins: VCC, GND, SCL, and SDA. On a Raspberry Pi Pico, connect VCC to 3.3V out (pin 36), GND to ground (pin 38), SCL to GP1 (pin 2, which is I2C0 SCL), and SDA to GP0 (pin 1, which is I2C0 SDA). On an ESP32-S3, use 3.3V and GND, then SCL to GPIO9 and SDA to GPIO8 for I2C0. On an Adafruit QT Py RP2040, you can use the STEMMA QT connector which already has VCC, GND, SCL, and SDA broken out, so you just plug in a JST SH cable. The I2C bus speed is typically 100kHz or 400kHz; the SSD1306 supports both, but 400kHz works fine for most applications. If you run into flickering, drop the frequency to 100kHz. The display draws about 12mA when idle and 20mA with all pixels on, so you can power it from a 3.3V regulator output rated for at least 50mA.

Installing CircuitPython and libraries
First, flash CircuitPython onto your board. For a Raspberry Pi Pico, download the latest .uf2 file from circuitpython.org, hold the BOOTSEL button while plugging in the USB, and copy the .uf2 file to the RPI-RP2 drive. For ESP32-S3, you need to use esptool or the Adafruit ESPTool to flash the .bin file. Once CircuitPython is running, you will see a CIRCUITPY drive appear. Create a folder called "lib" if it doesn't exist. Download the Adafruit CircuitPython SSD1306 library from GitHub (adafruit_ssd1306.mpy) and the Adafruit CircuitPython framebuf library (adafruit_framebuf.mpy). Copy both .mpy files into the lib folder. These libraries are about 4KB and 8KB respectively, so they fit easily on the small flash of most boards. You also need the busio and board modules, which are built into CircuitPython, so no extra files needed.

Initializing the display in code
Open a code.py file on the CIRCUITPY drive. Start by importing the required modules: import board, busio, adafruit_ssd1306. Then create an I2C object: i2c = busio.I2C(board.GP1, board.GP0) for Pico, or i2c = busio.I2C(board.SCL, board.SDA) for boards with default I2C pins. Then create the display object: oled = adafruit_ssd1306.SSD1306_I2C(128, 64, i2c, addr=0x3C). The addr parameter defaults to 0x3C, but if your module uses 0x3D, change it. You can scan the I2C bus first with while not i2c.try_lock(): pass; print([hex(device) for device in i2c.scan()]) to confirm the address. If the scan returns nothing, check your wiring. Once initialized, you can clear the display: oled.fill(0) and oled.show(). The fill(0) sets all pixels to off (black), and fill(1) sets them to on (white). The show() command transfers the buffer to the display RAM. The buffer is 1024 bytes (128 * 64 / 8), which is stored in the microcontroller's RAM, not on the display.

Drawing text and shapes
To draw text, you need a font. The Adafruit library includes a built-in 5x7 pixel font. Use oled.text("Hello", 0, 0, 1) where the first two numbers are x and y coordinates (0 to 127 and 0 to 63), and the last number is color (1 for white, 0 for black). Then call oled.show(). The text will be 5 pixels wide per character, so a 128 pixel wide display can fit about 25 characters per line. You can fit up to 8 lines of text if you use 8 pixel line spacing (7 pixel font height plus 1 pixel gap). For shapes, use oled.pixel(x, y, 1) to set a single pixel, oled.hline(x, y, width, 1) for a horizontal line, oled.vline(x, y, height, 1) for a vertical line, oled.line(x0, y0, x1, y1, 1) for a diagonal line, oled.rect(x, y, width, height, 1) for an outline rectangle, and oled.fill_rect(x, y, width, height, 1) for a filled rectangle. The framebuf library also supports circles and triangles, but you need to use the framebuf methods directly on the display's buffer. For example, oled.circle(64, 32, 10, 1) draws a circle centered at (64,32) with radius 10. All drawing commands modify the buffer in RAM, so you must call oled.show() to update the display. The update rate is about 30 frames per second for simple text, but drops to 10 FPS if you redraw the entire screen with complex graphics.

Performance and memory considerations
The SSD1306 uses a 128x64 pixel buffer that is 1024 bytes in size. On a Raspberry Pi Pico with 264KB of RAM, this is negligible. On an ESP32-S3 with 512KB of SRAM, it's also fine. But on a board like the Adafruit Trinket M0 with only 32KB of RAM, the 1024 byte buffer plus the library overhead (about 2KB) leaves about 28KB for your program, which is usually enough for simple displays. If you need more memory, you can reduce the buffer size by using a smaller display area, but the SSD1306 does not support partial updates natively; you must redraw the entire buffer. To improve performance, avoid calling oled.show() after every single pixel change. Instead, batch all drawing commands and call show() once. For scrolling text, you can shift the buffer by copying rows, but that is slow. A better approach is to use the scroll() method if your firmware supports it, but CircuitPython's SSD1306 library does not implement hardware scrolling. You can implement software scrolling by using framebuf's blit() method to copy a portion of the buffer.

Common issues and troubleshooting
If the display stays blank, check the I2C address. Some modules use 0x3D instead of 0x3C. You can also try adding a 10k ohm pull-up resistor on SDA and SCL lines if your board doesn't have internal pull-ups. Most microcontrollers have internal pull-ups, but they are weak (about 50k ohms), so external 4.7k or 10k resistors are safer. If the display shows random pixels or flickers, you might have a loose connection or the I2C bus speed is too high. Drop the frequency to 100kHz by using i2c = busio.I2C(board.GP1, board.GP0, frequency=100000). If the display is very dim, check the contrast setting. The default contrast is 127 (0x7F), but you can change it with oled.contrast(255) for maximum brightness. However, higher contrast draws more current. If you see ghosting or afterimages, you can enable the internal charge pump by setting oled.poweron() and then oled.contrast(200) to stabilize the voltage. The display's internal charge pump generates about 7V to 8V for the OLED pixels, and it needs about 10ms to stabilize after power-on.

Advanced usage: bitmap images and custom fonts
You can display bitmap images by converting them to a bytearray. For a 128x64 monochrome image, you need 1024 bytes. Use a tool like ImageMagick or the Adafruit ImageConverter to convert a PNG to a 1-bit bitmap in C array format. Then copy the bytearray into your code and use oled.image(bytearray, 0, 0, 1, 1) where the parameters are the bytearray, x offset, y offset, width, and height. The image must be 128 pixels wide and 64 pixels tall, or you can use a smaller image and center it. For custom fonts, you can use the adafruit_bitmap_font library which supports BDF and PCF fonts. Download a font file like "5x7.bdf" from the Adafruit GitHub, place it in the CIRCUITPY drive, and load it with font = adafruit_bitmap_font.load_font("5x7.bdf"). Then use oled.text(font, "Hello", 0, 0, 1) to draw with that font. The bitmap font library uses more RAM because it loads the entire font glyph table. A typical 5x7 font file is about 2KB, but a 12x16 font can be 8KB or more. This is fine on a Pico but may be tight on a Trinket M0.

Power consumption and battery operation
The 0.96 inch OLED draws about 12mA when displaying a static image with 50% pixels on, and 20mA with all pixels on. In sleep mode, you can call oled.poweroff() to turn off the display, which drops current to about 1uA. To wake it up, call oled.poweron() and then reinitialize the display if needed. For battery-powered projects, you can use a P-channel MOSFET to cut power to the display entirely, but the I2C lines will still be powered, so you need to disable the I2C peripheral or set the pins to high impedance to avoid leakage current. The display's internal charge pump can cause a brief inrush current of about 50mA for 1ms when powering on, so ensure your power supply can handle that. If you are using a 3.7V LiPo battery, you need a 3.3V regulator like the MCP1700 or AP2112, which can deliver 250mA and has a dropout voltage of 0.4V. The OLED's VCC pin can accept 3.0V to 3.6V, so a regulated 3.3V is ideal. Do not connect the OLED directly to a LiPo battery because the voltage range (3.0V to 4.2V) exceeds the maximum rating.

Comparing to other display options
The 0.96 inch OLED is popular because it is small, bright, and has a high contrast ratio of 2000:1. It is much easier to read than a 16x2 character LCD, which has a contrast ratio of about 100:1 and requires a backlight that draws 50mA to 100mA. The OLED also has a faster response time of about 10 microseconds, compared to an LCD's 10 milliseconds. However, the OLED has a limited lifespan of about 10,000 to 20,000 hours of operation, depending on brightness, while an LCD can last 50,000 hours. The OLED's pixels degrade over time, especially if you display the same static image for long periods, causing burn-in. To mitigate this, you can use screen savers or invert the display periodically. The 128x64 resolution is enough for simple graphs, menus, or text, but not for detailed images. If you need more resolution, consider a 128x128 or 240x240 OLED, but those require more RAM and a faster I2C bus or SPI interface. The I2C version of this display runs at 400kHz, which gives a theoretical maximum of 400kbps, but the actual throughput is lower due to protocol overhead. For a full screen update, you need to send 1024 bytes plus 2 bytes per page, which takes about 3ms at 400kHz. That is fast enough for 30 FPS updates, but if you need faster, use the SPI version which can update in under 1ms.

Real-world example: a weather station display
Let's say you want to build a simple weather station that shows temperature, humidity, and pressure on the OLED. You would use a BME280 sensor connected to the same I2C bus. The BME280 has an I2C address of 0x76 or 0x77, so it won't conflict with the OLED's 0x3C. In your code, you read the sensor every 2 seconds, format the data into strings, clear the display, draw the text, and call show(). The update rate of 0.5 Hz is fine for weather data. The display will show something like "Temp: 23.5C" on line 1, "Hum: 45%" on line 2, "Pres: 1013hPa" on line 3. You can also draw a small bar graph for humidity using fill_rect(). The total code size is about 2KB, and the library overhead is about 4KB, so it fits on any CircuitPython board. The power consumption is about 15mA for the OLED plus 1mA for the BME280, so a 1000mAh LiPo battery would last about 60 hours continuously, or much longer if you use sleep modes.

Using the display with a rotary encoder for menus
Another common use case is a menu system controlled by a rotary encoder. You can read the encoder's position and button press, then update the display to show different menu items. The OLED's 128x64 resolution allows you to show 4 to 5 lines of text at 8 pixel font height, or 2 lines at 16 pixel font height. For a menu, you can use a 12x16 font for readability. You can highlight the selected item by inverting the text: use oled.fill_rect(x, y, width, 16, 1) to draw a white background, then draw black text on top using oled.text("Item", x, y, 0). The contrast of the OLED is so high that the inverted text is very readable. The encoder debouncing can be done in software with a 5ms delay, and the display update should be limited to 10 Hz to avoid flicker. The total current draw with the encoder and OLED is about 20mA, so a 500mAh battery would last about 25 hours.

I2C bus limitations and multiple devices
If you have multiple I2C devices on the same bus, each must have a unique address. The OLED uses 0x3C or 0x3D, and you can change the address by soldering a resistor on the back of the module. Some modules have a jumper that changes the address to 0x3D. The I2C bus capacitance limits the total cable length to about 1 meter at 100kHz, but for the OLED, you usually keep the wires under 10cm. If you need longer wires, use shielded twisted pair and lower the frequency to 50kHz. The bus can handle up to 400pF of capacitance, and each device adds about 10pF to 20pF. So you can theoretically connect up to 20 devices, but in practice, 5 to 10 devices is the limit before signal integrity degrades. If you have trouble with multiple devices, add a 4.7k ohm pull-up resistor on SDA and SCL to 3.3V, and ensure the bus is not overloaded.

Firmware updates and compatibility
CircuitPython is updated frequently, and the SSD1306 library is maintained by Adafruit. As of 2025, the latest version is CircuitPython 9.x, which supports the SSD1306 library version 2.12.0. The library works with both 128x32 and 128x64 displays, but you must specify the correct height in the constructor. If you use a 128x32 display, change the height to 32. The library also supports the SSD1305 and SSD1309 drivers, but the 0.96 inch display uses the SSD1306. If you are using an older board like the