From cf55e7524f7b68c53313142b53c8e061d53b38b8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=A9bastien=20NEDJAR?= Date: Fri, 10 Apr 2026 11:39:18 +0200 Subject: [PATCH] feat(steami_screen): Add SSD1327 display wrapper. --- lib/steami_screen/examples/watch.py | 32 +++++++++ lib/steami_screen/steami_screen/__init__.py | 2 + lib/steami_screen/steami_screen/device.py | 76 ++++++++------------- lib/steami_screen/steami_screen/ssd1327.py | 51 ++++++++++++++ 4 files changed, 112 insertions(+), 49 deletions(-) create mode 100644 lib/steami_screen/examples/watch.py create mode 100644 lib/steami_screen/steami_screen/ssd1327.py diff --git a/lib/steami_screen/examples/watch.py b/lib/steami_screen/examples/watch.py new file mode 100644 index 00000000..51787cce --- /dev/null +++ b/lib/steami_screen/examples/watch.py @@ -0,0 +1,32 @@ +""" +Tutorial 09 — Analog Watch +Displays an analog clock face using the built-in RTC. +""" + +import time + +import ssd1327 +from machine import RTC, SPI, Pin +from steami_screen import Screen, SSD1327Display + +# --- Screen setup --- +spi = SPI(1) +dc = Pin("DATA_COMMAND_DISPLAY") +res = Pin("RST_DISPLAY") +cs = Pin("CS_DISPLAY") + +display = SSD1327Display(ssd1327.WS_OLED_128X128_SPI(spi, dc, res, cs)) +screen = Screen(display) + +# --- RTC setup --- +rtc = RTC() + +# --- Main loop --- +while True: + _, _, _, _, h, m, s, _ = rtc.datetime() + + screen.clear() + screen.watch(h, m, s) + screen.show() + + time.sleep(0.5) diff --git a/lib/steami_screen/steami_screen/__init__.py b/lib/steami_screen/steami_screen/__init__.py index 46773145..92e35230 100644 --- a/lib/steami_screen/steami_screen/__init__.py +++ b/lib/steami_screen/steami_screen/__init__.py @@ -11,6 +11,7 @@ YELLOW, Screen, ) +from steami_screen.ssd1327 import SSD1327Display __all__ = [ "BLACK", @@ -22,6 +23,7 @@ "RED", "WHITE", "YELLOW", + "SSD1327Display", "Screen", "rgb_to_gray4", "rgb_to_rgb8", diff --git a/lib/steami_screen/steami_screen/device.py b/lib/steami_screen/steami_screen/device.py index 5a63c173..2354d9d5 100644 --- a/lib/steami_screen/steami_screen/device.py +++ b/lib/steami_screen/steami_screen/device.py @@ -15,8 +15,6 @@ import math # --- Color constants (RGB tuples) --- -from steami_screen.colors import rgb_to_gray4 - # Grays map to exact SSD1327 levels: gray4 * 17 gives R=G=B BLACK = (0, 0, 0) DARK = (102, 102, 102) # gray4=6 @@ -52,14 +50,6 @@ def __init__(self, display, width=None, height=None): self._d = display self.width = width or getattr(display, 'width', 128) self.height = height or getattr(display, 'height', 128) - # Detect if the backend needs greyscale ints (SSD1327) or accepts RGB tuples - self._needs_gray = not hasattr(display, 'fill_rect') or hasattr(display, 'framebuf') - - def _c(self, color): - """Convert color for the backend. Returns gray4 int for SSD1327, pass-through otherwise.""" - if self._needs_gray: - return rgb_to_gray4(color) - return color # --- Adaptive properties --- @@ -121,7 +111,7 @@ def _resolve(self, at, text_len=0, scale=1): def title(self, text, color=GRAY): """Draw title text at the top (N).""" x, y = self._resolve("N", len(text)) - self._text(text, x, y, color) + self._d.text(text, x, y, color) def value(self, val, unit=None, at="CENTER", label=None, color=WHITE, scale=2, y_offset=0): @@ -156,7 +146,7 @@ def value(self, val, unit=None, at="CENTER", label=None, # Optional label above if label: lx = x + tw // 2 - len(label) * self.CHAR_W // 2 - self._text(label, lx, y - self.CHAR_H - 4, GRAY) + self._d.text(label, lx, y - self.CHAR_H - 4, GRAY) # Value (large) self._draw_scaled_text(text, x, y, color, scale) @@ -168,7 +158,7 @@ def value(self, val, unit=None, at="CENTER", label=None, if hasattr(self._d, 'draw_medium_text'): self._d.draw_medium_text(unit, ux, unit_y, LIGHT) else: - self._text(unit, ux, unit_y, LIGHT) + self._d.text(unit, ux, unit_y, LIGHT) def subtitle(self, *lines, color=DARK): """Draw subtitle text at the bottom (S). Accepts multiple lines.""" @@ -185,7 +175,7 @@ def subtitle(self, *lines, color=DARK): block_h = (n - 1) * line_h start_y = base_y - block_h // 2 - draw = getattr(self._d, 'draw_small_text', self._text) + draw = getattr(self._d, 'draw_small_text', self._d.text) for i, line in enumerate(lines): x, _ = self._resolve("S", len(line)) y = start_y + i * line_h @@ -246,7 +236,7 @@ def gauge(self, val, min_val=0, max_val=100, unit=None, color=LIGHT): if hasattr(self._d, 'draw_medium_text'): self._d.draw_medium_text(unit, ux, uy, LIGHT) else: - self._text(unit, ux, uy, LIGHT) + self._d.text(unit, ux, uy, LIGHT) # Min/max labels at arc endpoints (slightly inward to stay visible) min_t = str(int(min_val)) @@ -259,7 +249,7 @@ def gauge(self, val, min_val=0, max_val=100, unit=None, color=LIGHT): ly = int(cy + r_label * math.sin(angle_s)) rx = int(cx + r_label * math.cos(angle_e)) - len(max_t) * self.CHAR_W // 2 ry = int(cy + r_label * math.sin(angle_e)) - draw_sm = getattr(self._d, 'draw_small_text', self._text) + draw_sm = getattr(self._d, 'draw_small_text', self._d.text) draw_sm(min_t, lx, ly, GRAY) draw_sm(max_t, rx, ry, GRAY) @@ -280,7 +270,7 @@ def graph(self, data, min_val=0, max_val=100, color=LIGHT): if data: text = str(int(data[-1])) draw_fn = getattr(self._d, 'draw_medium_text', - self._text) + self._d.text) tw = len(text) * self.CHAR_W vx = cx - tw // 2 vy = 31 @@ -292,7 +282,7 @@ def _fmt(v): return str(int(v // 1000)) + "k" return str(int(v)) - draw_sm = getattr(self._d, 'draw_small_text', self._text) + draw_sm = getattr(self._d, 'draw_small_text', self._d.text) mid_val = (min_val + max_val) / 2 for val, yp in [(max_val, gy), (mid_val, gy + gh // 2), @@ -349,9 +339,9 @@ def menu(self, items, selected=0, color=WHITE): iy = y + (i - start) * item_h if i == selected: self._fill_rect(15, iy - 2, self.width - 30, item_h, DARK) - self._text("> " + items[i], 18, iy, color) + self._d.text("> " + items[i], 18, iy, color) else: - self._text(" " + items[i], 18, iy, GRAY) + self._d.text(" " + items[i], 18, iy, GRAY) def compass(self, heading, color=LIGHT): """Draw a compass with a rotating needle.""" @@ -367,7 +357,7 @@ def compass(self, heading, color=LIGHT): lx = cx + int((r + 5) * math.sin(math.radians(angle))) ly = cy - int((r + 5) * math.cos(math.radians(angle))) c = WHITE if label == "N" else GRAY - self._text(label, lx - self.CHAR_W // 2, ly - self.CHAR_H // 2, c) + self._d.text(label, lx - self.CHAR_W // 2, ly - self.CHAR_H // 2, c) # Tick marks (8 directions) for angle in range(0, 360, 45): @@ -435,7 +425,7 @@ def watch(self, hours, minutes, seconds=0, color=LIGHT): lx = cx + int((r - 15) * math.sin(rad)) ly = cy - int((r - 15) * math.cos(rad)) tw = len(text) * self.CHAR_W - self._text(text, lx - tw // 2, ly - self.CHAR_H // 2, WHITE) + self._d.text(text, lx - tw // 2, ly - self.CHAR_H // 2, WHITE) # Hour hand (short, thick) h_angle = (hours % 12 + minutes / 60) * 30 @@ -514,7 +504,7 @@ def text(self, text, at="CENTER", color=WHITE, scale=1): if scale > 1: self._draw_scaled_text(text, x, y, color, scale) else: - self._text(text, x, y, color) + self._d.text(text, x, y, color) def line(self, x1, y1, x2, y2, color=WHITE): self._line(x1, y1, x2, y2, color) @@ -532,49 +522,37 @@ def rect(self, x, y, w, h, color=WHITE, fill=False): self._rect(x, y, w, h, color) def pixel(self, x, y, color=WHITE): - self._pixel(x, y, self._c(color)) + self._d.pixel(x, y, color) # --- Control --- def clear(self, color=BLACK): - self._d.fill(self._c(color)) + self._d.fill(color) def show(self): self._d.show() # --- Internal drawing helpers --- - def _text(self, text, x, y, c): - self._d.text(text, x, y, self._c(c)) - - def _pixel(self, x, y, c): - self._d.pixel(x, y, self._c(c)) - def _line(self, x1, y1, x2, y2, c): - self._d.line(x1, y1, x2, y2, self._c(c)) + self._d.line(x1, y1, x2, y2, c) def _hline(self, x, y, w, c): - self._d.line(x, y, x + w - 1, y, self._c(c)) + self._d.line(x, y, x + w - 1, y, c) def _vline(self, x, y, h, c): - self._d.line(x, y, x, y + h - 1, self._c(c)) + self._d.line(x, y, x, y + h - 1, c) def _fill_rect(self, x, y, w, h, c): - cc = self._c(c) if hasattr(self._d, 'fill_rect'): - self._d.fill_rect(x, y, w, h, cc) - elif hasattr(self._d, 'framebuf'): - self._d.framebuf.fill_rect(x, y, w, h, cc) + self._d.fill_rect(x, y, w, h, c) else: for row in range(h): - self._d.line(x, y + row, x + w - 1, y + row, cc) + self._d.line(x, y + row, x + w - 1, y + row, c) def _rect(self, x, y, w, h, c): - cc = self._c(c) if hasattr(self._d, 'rect'): - self._d.rect(x, y, w, h, cc) - elif hasattr(self._d, 'framebuf'): - self._d.framebuf.rect(x, y, w, h, cc) + self._d.rect(x, y, w, h, c) else: self._hline(x, y, w, c) self._hline(x, y + h - 1, w, c) @@ -594,7 +572,7 @@ def _draw_scaled_text(self, text, x, y, color, scale): # On real hardware without scaled text support, draw at scale=1 # centered at the same position (best effort) if not hasattr(self._d, 'pixel'): - self._text(text, x, y, color) + self._d.text(text, x, y, color) return # Render at 1x to a temporary buffer, then scale up # For MicroPython: draw each character using the display's text method @@ -602,13 +580,13 @@ def _draw_scaled_text(self, text, x, y, color, scale): if scale == 2: for dx in range(2): for dy in range(2): - self._text(text, x + dx, y + dy, color) + self._d.text(text, x + dx, y + dy, color) elif scale == 3: for dx in range(3): for dy in range(3): - self._text(text, x + dx, y + dy, color) + self._d.text(text, x + dx, y + dy, color) else: - self._text(text, x, y, color) + self._d.text(text, x, y, color) def _draw_arc(self, cx, cy, r, start_deg, sweep_deg, color, width=3): """Draw a thick arc using individual pixels.""" @@ -623,7 +601,7 @@ def _draw_arc(self, cx, cy, r, start_deg, sweep_deg, color, width=3): x = int(cx + (r + dr) * math.cos(angle)) y = int(cy + (r + dr) * math.sin(angle)) if 0 <= x < self.width and 0 <= y < self.height: - self._pixel(x, y, color) + self._d.pixel(x, y, color) def _draw_circle(self, cx, cy, r, color): """Bresenham circle.""" @@ -633,7 +611,7 @@ def _draw_circle(self, cx, cy, r, color): (x, -y), (y, -x), (-x, -y), (-y, -x)): px, py = cx + sx, cy + sy if 0 <= px < self.width and 0 <= py < self.height: - self._pixel(px, py, color) + self._d.pixel(px, py, color) y += 1 if d < 0: d += 2 * y + 1 diff --git a/lib/steami_screen/steami_screen/ssd1327.py b/lib/steami_screen/steami_screen/ssd1327.py new file mode 100644 index 00000000..c650a716 --- /dev/null +++ b/lib/steami_screen/steami_screen/ssd1327.py @@ -0,0 +1,51 @@ +"""SSD1327 display wrapper — converts RGB colors to 4-bit grayscale. + +Wraps the raw SSD1327 driver so that steami_screen can pass RGB tuples +while the hardware receives grayscale values (0-15). + +Usage on the STeaMi board: + import ssd1327 + from steami_screen import SSD1327Display + raw = ssd1327.WS_OLED_128X128_SPI(spi, dc, res, cs) + display = SSD1327Display(raw) +""" + +from steami_screen.colors import rgb_to_gray4 + + +class SSD1327Display: + """Thin wrapper around an SSD1327 driver that accepts RGB colors.""" + + def __init__(self, raw): + self._raw = raw + self.width = getattr(raw, 'width', 128) + self.height = getattr(raw, 'height', 128) + + def fill(self, color): + self._raw.fill(rgb_to_gray4(color)) + + def pixel(self, x, y, color): + self._raw.pixel(x, y, rgb_to_gray4(color)) + + def text(self, string, x, y, color): + self._raw.text(string, x, y, rgb_to_gray4(color)) + + def line(self, x1, y1, x2, y2, color): + self._raw.line(x1, y1, x2, y2, rgb_to_gray4(color)) + + def fill_rect(self, x, y, w, h, color): + gray = rgb_to_gray4(color) + if hasattr(self._raw, 'fill_rect'): + self._raw.fill_rect(x, y, w, h, gray) + else: + self._raw.framebuf.fill_rect(x, y, w, h, gray) + + def rect(self, x, y, w, h, color): + gray = rgb_to_gray4(color) + if hasattr(self._raw, 'rect'): + self._raw.rect(x, y, w, h, gray) + else: + self._raw.framebuf.rect(x, y, w, h, gray) + + def show(self): + self._raw.show()