Created
November 6, 2025 19:25
-
-
Save pwillard/c10b2239c494398604ef5d2078bd2d8d to your computer and use it in GitHub Desktop.
A python script for Raspberry Pi 0.96 128x64 OLED display
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| #!/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() |
Author
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
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.