Skip to content

Instantly share code, notes, and snippets.

@pwillard
Created November 6, 2025 19:25
Show Gist options
  • Select an option

  • Save pwillard/c10b2239c494398604ef5d2078bd2d8d to your computer and use it in GitHub Desktop.

Select an option

Save pwillard/c10b2239c494398604ef5d2078bd2d8d to your computer and use it in GitHub Desktop.
A python script for Raspberry Pi 0.96 128x64 OLED display
#!/usr/bin/python3
# -*- coding: utf-8 -*-
# Pete Willard Nov 2025
# A python script for Raspberry Pi 0.96 128x64 OLED display
"""
#!/usr/bin/env bash
exec /usr/bin/env python3 /opt/oled/oled.py \
--display ssd1306 --interface i2c --i2c-port 1 --i2c-address 0x3C \
--width 128 --height 64 --band-split 16 \
--fontsize 9 --interval 2.0 --shift-x 1 --shift-y 0 \
--invert-every 90 --blank-every 240 \
--alt-period 5 --night-start 22 --night-end 7 \
--day-contrast 255 --night-contrast 40
"""
import sys
import time
import socket
import argparse
import random
from pathlib import Path
from datetime import datetime
import psutil
from luma.core.render import canvas
from luma.core.interface.serial import i2c, spi
from luma.oled.device import ssd1306, ssd1327, ssd1351
from PIL import ImageFont
# ---------- Helpers ----------
def bytes2human(n: int) -> str:
symbols = ('K', 'M', 'G', 'T', 'P', 'E', 'Z', 'Y')
prefix = {s: 1 << ((i + 1) * 10) for i, s in enumerate(symbols)}
for s in reversed(symbols):
if n >= prefix[s]:
value = float(n) / prefix[s]
return f"{int(value) if value.is_integer() else round(value,1)}{s}"
return f"{n}B"
def uptime_str() -> str:
up = datetime.now() - datetime.fromtimestamp(psutil.boot_time())
d = up.days
h = up.seconds // 3600
m = (up.seconds % 3600) // 60
return f"Up:{d}d {h}h {m}m" if d else f"Up:{h}h {m}m"
def clock_str() -> str:
now = datetime.now()
# Example: "13:42 Thu 06"
return now.strftime("%H:%M %a %d")
def ip_addr_v4() -> str:
for _, addrs in psutil.net_if_addrs().items():
for a in addrs:
if a.family == socket.AF_INET and not a.address.startswith("127."):
return a.address
return "0.0.0.0"
def mem_usage_str() -> str:
vm = psutil.virtual_memory()
return f"Mem:{bytes2human(vm.used)} {vm.percent:.0f}%"
def disk_usage_str(mount: str = "/") -> str:
du = psutil.disk_usage(mount)
return f"SD:{bytes2human(du.used)} {du.percent:.0f}%"
def pick_iface(preferred: str | None = None) -> str | None:
stats = psutil.net_if_stats()
if preferred and preferred in stats and stats[preferred].isup:
return preferred
for name, st in stats.items():
if st.isup and not name.lower().startswith(("lo", "loop")):
return name
return None
class NetRate:
"""Tracks per-second TX/RX rates per interface."""
def __init__(self):
self._last = {}
def rate(self, iface: str) -> tuple[int, int]:
now = time.monotonic()
counters = psutil.net_io_counters(pernic=True)
if iface not in counters:
raise KeyError(iface)
cur_tx = counters[iface].bytes_sent
cur_rx = counters[iface].bytes_recv
if iface in self._last:
prev_tx, prev_rx, prev_t = self._last[iface]
dt = max(1e-6, now - prev_t)
txps = int((cur_tx - prev_tx) / dt)
rxps = int((cur_rx - prev_rx) / dt)
else:
txps = rxps = 0
self._last[iface] = (cur_tx, cur_rx, now)
return txps, rxps
def rate_str(bps: int) -> str:
return f"{bps}/s" if bps < 1024 else f"{bytes2human(bps)}/s"
# ---------- Wander & Pulses ----------
class Wander:
"""Pixel wander within bounds; separate X/Y shifts."""
def __init__(self, shift_x: int, shift_y: int):
self.shift_x = shift_x
self.shift_y = shift_y
self.x = 0
self.y = 0
self._bounds = (0, 0, 0, 0)
def set_bounds(self, xmin, ymin, xmax, ymax):
self._bounds = (xmin, ymin, xmax, ymax)
self.x = min(max(self.x, xmin), xmax)
self.y = min(max(self.y, ymin), ymax)
def step(self):
xmin, ymin, xmax, ymax = self._bounds
self.x = min(max(self.x + random.randint(-self.shift_x, self.shift_x), xmin), xmax)
self.y = min(max(self.y + random.randint(-self.shift_y, self.shift_y), ymin), ymax)
return self.x, self.y
class BurnInManager:
"""Invert and blank pulses."""
def __init__(self, invert_every, blank_every, blank_duration):
self.invert_every = invert_every
self.blank_every = blank_every
self.blank_duration = blank_duration
self._last_invert = time.monotonic()
self._last_blank = time.monotonic()
self._blank_until = 0.0
self._invert_on = False
def mode(self) -> str:
now = time.monotonic()
if now < self._blank_until:
return "blank"
if self.blank_every > 0 and (now - self._last_blank) >= self.blank_every:
self._last_blank = now
self._blank_until = now + self.blank_duration
return "blank"
if self.invert_every > 0 and (now - self._last_invert) >= self.invert_every:
self._last_invert = now
self._invert_on = not self._invert_on
return "invert" if self._invert_on else "normal"
# ---------- Drawing ----------
def load_font(size: int = 9) -> ImageFont.FreeTypeFont:
path = "/usr/share/fonts/truetype/dejavu/DejaVuSansMono.ttf"
if Path(path).exists():
return ImageFont.truetype(path, size)
return ImageFont.load_default()
def measure_block(font, lines):
lh = font.getbbox("Ag")[3] + 1
width = 0
for s in lines:
w = int(font.getlength(s) if hasattr(font, "getlength") else font.getbbox(s)[2])
width = max(width, w)
height = lh * len(lines)
return width, height, lh
def draw_band(device, draw, font, lines, ox, oy, fg):
lh = font.getbbox("Ag")[3] + 1
y = oy
for s in lines:
draw.text((ox, y), s, font=font, fill=fg)
y += lh
def draw_stats(device, font, iface, netrate, mode, top_xy, bot_xy,
top_lines, bot_lines):
with canvas(device) as draw:
if mode == "blank":
draw.rectangle(device.bounding_box, fill="black")
return
fg, bg = ("black", "white") if mode == "invert" else ("white", "black")
draw.rectangle(device.bounding_box, fill=bg)
draw_band(device, draw, font, top_lines, top_xy[0], top_xy[1], fg)
draw_band(device, draw, font, bot_lines, bot_xy[0], bot_xy[1], fg)
# ---------- Device, Night mode & Args ----------
def parse_args(argv=None):
ap = argparse.ArgumentParser(description="Two-color 128x64 OLED monitor (band-aware, clock/uptime alternation)")
# app settings
ap.add_argument("--iface", default="wlan0", help="Preferred network interface")
ap.add_argument("--interval", type=float, default=2.0)
ap.add_argument("--fontsize", type=int, default=9)
ap.add_argument("--margin", type=int, default=2)
ap.add_argument("--invert-every", type=int, default=90)
ap.add_argument("--blank-every", type=int, default=240)
ap.add_argument("--blank-duration", type=float, default=1.5)
# alternation
ap.add_argument("--alt-period", type=int, default=5, help="Seconds between UPTIME↔CLOCK swap")
# band layout & wander
ap.add_argument("--band-split", type=int, default=16, help="Y pixel where top color ends")
ap.add_argument("--shift-x", type=int, default=1, help="Horizontal wander per frame")
ap.add_argument("--shift-y", type=int, default=0, help="Vertical wander per frame (0 keeps bands safe)")
# night mode (local time)
ap.add_argument("--night-start", type=int, default=22, help="Hour (0-23) when night mode starts")
ap.add_argument("--night-end", type=int, default=7, help="Hour (0-23) when night mode ends")
ap.add_argument("--day-contrast", type=int, default=255, help="SSD1306 contrast by day (0-255)")
ap.add_argument("--night-contrast", type=int, default=40, help="SSD1306 contrast by night (0-255)")
# display settings (defaults = SSD1306 over I2C @ 0x3C, 128x64)
ap.add_argument("--display", default="ssd1306", choices=["ssd1306", "ssd1327", "ssd1351"])
ap.add_argument("--interface", default="i2c", choices=["i2c", "spi"])
ap.add_argument("--width", type=int, default=128)
ap.add_argument("--height", type=int, default=64)
# i2c
ap.add_argument("--i2c-port", type=int, default=1)
ap.add_argument("--i2c-address", type=lambda x: int(x, 0), default="0x3C")
# spi
ap.add_argument("--spi-port", type=int, default=0)
ap.add_argument("--spi-device", type=int, default=0)
ap.add_argument("--gpio-dc", type=int, default=24)
ap.add_argument("--gpio-rst", type=lambda x: None if x.lower()=="none" else int(x), default=None)
return ap.parse_args(argv)
def get_device_from_args(args):
if args.interface == "i2c":
serial = i2c(port=args.i2c_port, address=args.i2c_address)
else:
serial = spi(port=args.spi_port, device=args.spi_device, gpio_DC=args.gpio_dc, gpio_RST=args.gpio_rst)
if args.display == "ssd1306":
return ssd1306(serial, width=args.width, height=args.height)
elif args.display == "ssd1327":
return ssd1327(serial, width=args.width, height=args.height)
elif args.display == "ssd1351":
return ssd1351(serial, width=args.width, height=args.height)
else:
raise SystemExit("Unsupported display type")
def in_night_hours(now: datetime, start: int, end: int) -> bool:
# handles ranges that pass midnight (e.g., 22 -> 07)
h = now.hour
if start == end:
return False
if start < end:
return start <= h < end
return h >= start or h < end
def apply_night_mode(device, args, last_state: dict):
"""Set contrast only when state changes; cache last applied value."""
night = in_night_hours(datetime.now(), args.night_start, args.night_end)
want = args.night_contrast if night else args.day_contrast
if last_state.get("contrast") != want:
try:
device.contrast(max(0, min(255, int(want))))
last_state["contrast"] = want
except Exception:
# Some drivers may not support contrast; ignore silently
last_state["contrast"] = None
# ---------- Main ----------
def main():
args = parse_args(sys.argv[1:])
device = get_device_from_args(args)
font = load_font(args.fontsize)
# Band-safe managers
wander_top = Wander(shift_x=args.shift_x, shift_y=args.shift_y) # IP in top band
wander_bot = Wander(shift_x=args.shift_x, shift_y=args.shift_y) # bottom block (uptime/clock, mem, disk, net)
pulses = BurnInManager(args.invert_every, args.blank_every, args.blank_duration)
netrate = NetRate()
iface = pick_iface(args.iface)
alt_t0 = time.monotonic() # for uptime/clock alternation
last_night = {"contrast": None}
try:
if iface:
netrate.rate(iface)
time.sleep(0.1)
while True:
# Night mode: adjust contrast if needed
apply_night_mode(device, args, last_night)
# Alternate the first bottom line between uptime and clock
show_clock = int((time.monotonic() - alt_t0) // max(1, args.alt_period)) % 2 == 1
first_line = clock_str() if show_clock else uptime_str()
# Build strings
ip = f"IP:{ip_addr_v4()}"
mem = mem_usage_str()
disk = disk_usage_str("/")
rows = [first_line, mem, disk]
if iface and iface in psutil.net_io_counters(pernic=True):
try:
txps, rxps = netrate.rate(iface)
rows.append(f"{iface}: Tx {rate_str(txps)} Rx {rate_str(rxps)}")
except KeyError:
pass
# Measure blocks
top_w, top_h, lh = measure_block(font, [ip])
bot_w, bot_h, _ = measure_block(font, rows)
# Fit bottom block by dropping least important lines from the end
while bot_h > max(0, device.height - args.band_split - args.margin*2) and len(rows) > 1:
rows.pop()
bot_w, bot_h, _ = measure_block(font, rows)
# Set wander bounds (no crossing bands)
# Top band
top_xmin = args.margin
top_xmax = max(args.margin, device.width - args.margin - top_w)
top_ymin = args.margin
top_ymax = max(args.margin, args.band_split - args.margin - top_h)
wander_top.set_bounds(top_xmin, top_ymin, top_xmax, top_ymax)
# Bottom band
bot_xmin = args.margin
bot_xmax = max(args.margin, device.width - args.margin - bot_w)
bot_ymin = max(args.band_split + args.margin, args.band_split)
bot_ymax = max(bot_ymin, device.height - args.margin - bot_h)
wander_bot.set_bounds(bot_xmin, bot_ymin, bot_xmax, bot_ymax)
# Step wander & draw
top_xy = wander_top.step()
bot_xy = wander_bot.step()
mode = pulses.mode()
draw_stats(device, font, iface, netrate, mode, top_xy, bot_xy,
top_lines=[ip], bot_lines=rows)
time.sleep(args.interval)
except KeyboardInterrupt:
with canvas(device) as draw:
draw.rectangle(device.bounding_box, fill="black")
if __name__ == "__main__":
main()
@pwillard
Copy link
Author

pwillard commented Nov 6, 2025

The OLED display in my GEEKPI Tower Case (Wavefront) burned due to default settings of example code. This is my fix for the replacement display.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment