ESP32 OLED Smart UI Eyes Animation Time & Weather (MicroPython) is a runnable microcontroller simulation example on SensBench — open the wired circuit, inspect the code, and compile real firmware in your browser, no install needed. Last updated Aug 14, 2026.

FREE SIMULATOR EXAMPLE · INTERMEDIATE

ESP32 OLED Smart UI Eyes Animation Time & Weather (MicroPython)

This example runs a smart UI on the ESP32’s OLED — animated eyes with random blinks and looks, a clock, and live weather, rotating in one display. The three-mode IoT face pattern in MicroPython.

Board ESP32 DevKit (Xtensa LX6, 240 MHz)
Difficulty intermediate
Category displays
Libraries None — just the Arduino core
Runs in Sensbench browser simulator — free, no account
ESP32 OLED smart UI with animated eyes, clock and weather in the Sensbench simulator

The exact circuit you get when you open this example — already wired, ready to run.

The sketch

# main.py
from machine import Pin, I2C
import ssd1306
from time import sleep, localtime, time
import network, ntptime
import urequests as requests
import urandom as random

# ================= CONFIG =================
SSID = "ssid"
PASSWORD = "pass"
# IST Offset: 5 hours 30 mins = (5*3600 + 30*60) = 19800 seconds
UTC_OFFSET = 19800
OWM_API_KEY = "key"
CITY = "city"

# ================= HARDWARE =================
# Hardware I2C (peripheral 0) — SoftI2C bit-bangs GPIO and the SENSBENCH
# ESP32 QEMU bridge can't route GPIO toggles back to the ssd1306
# slave registered at 0x3C, so write_cmd fails with ENODEV. Hardware
# I2C goes through the emulated I2C controller and reaches the slave.
i2c = I2C(0, scl=Pin(22), sda=Pin(21))
oled = ssd1306.SSD1306_I2C(128, 64, i2c)

# PULL_DOWN, not plain Pin.IN: the buttons sit between 3V3 and the pin, so
# without a pull-down the pin FLOATS while the button is open and pin.value()
# is meaningless. On a real board you read noise; here the node resolves to
# 3.3 V and both buttons look pressed forever, with wait_for_release()
# spinning on nothing. Same failure as on real hardware, not an emulator artifact.
touch_next = Pin(14, Pin.IN, Pin.PULL_DOWN)
touch_sel  = Pin(27, Pin.IN, Pin.PULL_DOWN)

# ================= UTILS =================
def wifi_connect():
    wlan = network.WLAN(network.STA_IF)
    wlan.active(True)
    if not wlan.isconnected():
        wlan.connect(SSID, PASSWORD)
        for _ in range(20):
            if wlan.isconnected(): break
            sleep(0.5)

def sync_time():
    try:
       
        ntptime.host = "pool.ntp.org" 
        ntptime.settime()
        print("Time Synced!")
        return True
    except:
        print("Sync Failed")
        return False

def get_local_time():
    
    return localtime(time() + UTC_OFFSET)

def wait_for_release(pin):
    while pin.value():
        sleep(0.05)

def pressed(pin):
    if pin.value():
        sleep(0.1) 
        wait_for_release(pin)
        return True
    return False

# ================= EYE SYSTEM  =================
def draw_eye(x, y, state=2, look=0):
    w, h = 24, 16
    if state == 0: # Closed
        oled.fill_rect(x, y + (h//2), w, 2, 1)
    elif state == 1: # Half-open
        oled.rect(x, y + (h//4), w, h//2, 1)
        oled.fill_rect(x + 8 + look, y + (h//4) + 2, 8, 4, 1)
    elif state == 2: # Fully Open
        oled.rect(x, y, w, h, 1)
        oled.fill_rect(x + 6 + look, y + 4, 10, 8, 1)

def animate_eyes_step():
    dirs = [-4, 0, 4]
    look_dir = dirs[random.getrandbits(8) % 3]
    for _ in range(30): 
        oled.fill(0)
        draw_eye(25, 22, state=2, look=look_dir)
        draw_eye(78, 22, state=2, look=look_dir)
        oled.show()
        if touch_sel.value():
            wait_for_release(touch_sel)
            return True 
        sleep(0.1)
    for p in [1, 0, 1]:
        oled.fill(0)
        draw_eye(25, 22, state=p, look=look_dir)
        draw_eye(78, 22, state=p, look=look_dir)
        oled.show()
        sleep(0.05)
    return False

# ================= SCREENS =================
def show_time_screen():
    while True:
        t = get_local_time()
        oled.fill(0)
        oled.text("TIME (IST)", 30, 0)
        # Time format: HH:MM:SS
        oled.text("{:02d}:{:02d}:{:02d}".format(t[3], t[4], t[5]), 30, 25)
        # Date format: DD/MM/YYYY
        oled.text("{:02d}/{:02d}/{}".format(t[2], t[1], t[0]), 22, 40)
        oled.text("Nxt:Sync,Sel:Ext", 0, 55)
        oled.show()
        
        if touch_next.value():
            sync_time()
            wait_for_release(touch_next)
        if pressed(touch_sel): 
            break

def show_weather_screen():
    oled.fill(0)
    oled.text("Loading...", 30, 25)
    oled.show()
    try:
        url = "http://api.openweathermap.org/data/2.5/weather?q={}&appid={}&units=metric".format(CITY, OWM_API_KEY)
        res = requests.get(url).json()
        temp, hum = res['main']['temp'], res['main']['humidity']
        desc = res['weather'][0]['main']
        while True:
            oled.fill(0)
            oled.text("WEATHER: " + CITY, 0, 0)
            oled.text("Temp: {}C".format(temp), 10, 20)
            oled.text("Hum:  {}%".format(hum), 10, 35)
            oled.text("Cond: " + desc, 10, 50)
            oled.show()
            if pressed(touch_sel): break
    except:
        oled.fill(0)
        oled.text("API Error", 30, 25)
        oled.show()
        sleep(2)

# ================= MAIN =================
WELCOME, EYES, MENU, TIME_S, WEATHER = range(5)
state = WELCOME
menu = ["TIME", "WEATHER", "BACK"]
menu_index = 0

wifi_connect()
sync_time()

while True:
    if state == WELCOME:
        oled.fill(0)
        oled.text("Welcome Kritish", 5, 25)
        oled.show()
        sleep(2)
        state = EYES

    elif state == EYES:
        if animate_eyes_step():
            state = MENU

    elif state == MENU:
        oled.fill(0)
        oled.text("-- MENU --", 30, 0)
        for i, item in enumerate(menu):
            marker = ">" if i == menu_index else " "
            oled.text("{} {}".format(marker, item), 10, 20 + i*12)
        oled.show()

        if pressed(touch_next):
            menu_index = (menu_index + 1) % len(menu)
        if pressed(touch_sel):
            choice = menu[menu_index]
            if choice == "TIME": state = TIME_S
            elif choice == "WEATHER": state = WEATHER
            else: state = EYES

    elif state == TIME_S:
        show_time_screen()
        state = MENU

    elif state == WEATHER:
        show_weather_screen()
        state = MENU


# ssd1306.py
# MicroPython SSD1306 OLED driver, I2C and SPI interfaces

from micropython import const
import framebuf


# register definitions
SET_CONTRAST = const(0x81)
SET_ENTIRE_ON = const(0xA4)
SET_NORM_INV = const(0xA6)
SET_DISP = const(0xAE)
SET_MEM_ADDR = const(0x20)
SET_COL_ADDR = const(0x21)
SET_PAGE_ADDR = const(0x22)
SET_DISP_START_LINE = const(0x40)
SET_SEG_REMAP = const(0xA0)
SET_MUX_RATIO = const(0xA8)
SET_COM_OUT_DIR = const(0xC0)
SET_DISP_OFFSET = const(0xD3)
SET_COM_PIN_CFG = const(0xDA)
SET_DISP_CLK_DIV = const(0xD5)
SET_PRECHARGE = const(0xD9)
SET_VCOM_DESEL = const(0xDB)
SET_CHARGE_PUMP = const(0x8D)

# Subclassing FrameBuffer provides support for graphics primitives
# http://docs.micropython.org/en/latest/pyboard/library/framebuf.html
class SSD1306(framebuf.FrameBuffer):
    def __init__(self, width, height, external_vcc):
        self.width = width
        self.height = height
        self.external_vcc = external_vcc
        self.pages = self.height // 8
        self.buffer = bytearray(self.pages * self.width)
        super().__init__(self.buffer, self.width, self.height, framebuf.MONO_VLSB)
        self.init_display()

    def init_display(self):
        for cmd in (
            SET_DISP | 0x00,  # off
            # address setting
            SET_MEM_ADDR,
            0x00,  # horizontal
            # resolution and layout
            SET_DISP_START_LINE | 0x00,
            SET_SEG_REMAP | 0x01,  # column addr 127 mapped to SEG0
            SET_MUX_RATIO,
            self.height - 1,
            SET_COM_OUT_DIR | 0x08,  # scan from COM[N] to COM0
            SET_DISP_OFFSET,
            0x00,
            SET_COM_PIN_CFG,
            0x02 if self.width > 2 * self.height else 0x12,
            # timing and driving scheme
            SET_DISP_CLK_DIV,
            0x80,
            SET_PRECHARGE,
            0x22 if self.external_vcc else 0xF1,
            SET_VCOM_DESEL,
            0x30,  # 0.83*Vcc
            # display
            SET_CONTRAST,
            0xFF,  # maximum
            SET_ENTIRE_ON,  # output follows RAM contents
            SET_NORM_INV,  # not inverted
            # charge pump
            SET_CHARGE_PUMP,
            0x10 if self.external_vcc else 0x14,
            SET_DISP | 0x01,
        ):  # on
            self.write_cmd(cmd)
        self.fill(0)
        self.show()

    def poweroff(self):
        self.write_cmd(SET_DISP | 0x00)

    def poweron(self):
        self.write_cmd(SET_DISP | 0x01)

    def contrast(self, contrast):
        self.write_cmd(SET_CONTRAST)
        self.write_cmd(contrast)

    def invert(self, invert):
        self.write_cmd(SET_NORM_INV | (invert & 1))

    def show(self):
        x0 = 0
        x1 = self.width - 1
        if self.width == 64:
            # displays with width of 64 pixels are shifted by 32
            x0 += 32
            x1 += 32
        self.write_cmd(SET_COL_ADDR)
        self.write_cmd(x0)
        self.write_cmd(x1)
        self.write_cmd(SET_PAGE_ADDR)
        self.write_cmd(0)
        self.write_cmd(self.pages - 1)
        self.write_data(self.buffer)


class SSD1306_I2C(SSD1306):
    def __init__(self, width, height, i2c, addr=0x3C, external_vcc=False):
        self.i2c = i2c
        self.addr = addr
        self.temp = bytearray(2)
        self.write_list = [b"\x40", None]  # Co=0, D/C#=1
        super().__init__(width, height, external_vcc)

    def write_cmd(self, cmd):
        self.temp[0] = 0x80  # Co=1, D/C#=0
        self.temp[1] = cmd
        self.i2c.writeto(self.addr, self.temp)

    def write_data(self, buf):
        self.write_list[1] = buf
        self.i2c.writevto(self.addr, self.write_list)


class SSD1306_SPI(SSD1306):
    def __init__(self, width, height, spi, dc, res, cs, external_vcc=False):
        self.rate = 10 * 1024 * 1024
        dc.init(dc.OUT, value=0)
        res.init(res.OUT, value=0)
        cs.init(cs.OUT, value=1)
        self.spi = spi
        self.dc = dc
        self.res = res
        self.cs = cs
        import time

        self.res(1)
        time.sleep_ms(1)
        self.res(0)
        time.sleep_ms(10)
        self.res(1)
        super().__init__(width, height, external_vcc)

    def write_cmd(self, cmd):
        self.spi.init(baudrate=self.rate, polarity=0, phase=0)
        self.cs(1)
        self.dc(0)
        self.cs(0)
        self.spi.write(bytearray([cmd]))
        self.cs(1)

    def write_data(self, buf):
        self.spi.init(baudrate=self.rate, polarity=0, phase=0)
        self.cs(1)
        self.dc(1)
        self.cs(0)
        self.spi.write(buf)
        self.cs(1)

How it works

The script runs a UI state machine on the OLED: an eye animation with randomized blinks and gaze shifts, a clock, and a weather panel fetched over WiFi. The rendering is all procedural — the eyes are built from geometric primitives redrawn each frame.

The rotation between modes is time-based, with the weather fetch happening periodically so the panel stays fresh without hammering the API. The animation timing uses frame counters, so the blink cadence feels organic rather than metronomic.

Before you build it for real

The weather mode depends on the API and WiFi: offline, the panel either shows stale data or a blank mode, so the UI must degrade gracefully — a real design ships with a cached fallback. The OLED’s refresh rate is a battery and life trade-off: continuous animation shortens both, which is why the rotation includes idle states. And the geometric eye rendering is resolution-bound — the 128×64 panel can only express so much expression; the animation reads as personality precisely because it stays within those limits.

Get the parts

Related examples

FAQ

Why animate eyes on an OLED?

Eyes give a device personality: the animation alternates blinking, looking around and idle states, which makes the gadget feel alive. It is also a rendering exercise — the eyes are drawn from arcs and ellipses, not image assets.

How does the weather get into the display?

The ESP32 fetches weather data over WiFi from a web API and renders the values in the UI rotation. The screen alternates between the eye animation, the clock and the weather view — a three-mode smart display.