Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
32 changes: 32 additions & 0 deletions lib/steami_screen/examples/watch.py
Original file line number Diff line number Diff line change
@@ -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)
2 changes: 2 additions & 0 deletions lib/steami_screen/steami_screen/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
YELLOW,
Screen,
)
from steami_screen.ssd1327 import SSD1327Display

__all__ = [
"BLACK",
Expand All @@ -22,6 +23,7 @@
"RED",
"WHITE",
"YELLOW",
"SSD1327Display",
"Screen",
"rgb_to_gray4",
"rgb_to_rgb8",
Expand Down
76 changes: 27 additions & 49 deletions lib/steami_screen/steami_screen/device.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 ---

Expand Down Expand Up @@ -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):
Expand Down Expand Up @@ -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)
Expand All @@ -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."""
Expand All @@ -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
Expand Down Expand Up @@ -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))
Expand All @@ -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)

Expand All @@ -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
Expand All @@ -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),
Expand Down Expand Up @@ -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."""
Expand All @@ -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):
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand All @@ -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:
Comment on lines 546 to 556
Copy link

Copilot AI Apr 10, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Screen no longer does backend-wide color conversion, but _fill_rect()/_rect() still special-case display.framebuf and force rgb_to_gray4(). This leaves an inconsistent state where a framebuf-backed display might get gray4 ints for rects but RGB tuples for fill()/line()/pixel()/text(), which will break or render incorrectly depending on the framebuffer format. If the wrapper is now the single conversion point, consider removing the framebuf branches here (and the rgb_to_gray4 dependency) so all drawing paths consistently pass through the backend API.

Copilot uses AI. Check for mistakes.
self._hline(x, y, w, c)
self._hline(x, y + h - 1, w, c)
Expand All @@ -594,21 +572,21 @@ 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
# but multiple times offset for a bold effect at scale 2
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."""
Expand All @@ -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."""
Expand All @@ -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
Expand Down
51 changes: 51 additions & 0 deletions lib/steami_screen/steami_screen/ssd1327.py
Original file line number Diff line number Diff line change
@@ -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()
Loading