diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 1ac42f3..d72e27f 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -100,7 +100,7 @@ Commit messages follow the [Conventional Commits](https://www.conventionalcommit **Scopes** (optional but enforced): if provided, the scope **must** be one of the allowed values. The scope is recommended for driver-specific changes but can be omitted for cross-cutting changes. -- Driver scopes: `apds9960`, `bme280`, `bq27441`, `daplink_bridge`, `daplink_flash`, `gc9a01`, `hts221`, `im34dt05`, `ism330dl`, `lis2mdl`, `mcp23009e`, `ssd1327`, `steami_config`, `vl53l1x`, `wsen-hids`, `wsen-pads` +- Driver scopes: `apds9960`, `bme280`, `bq27441`, `daplink_bridge`, `daplink_flash`, `gc9a01`, `hts221`, `im34dt05`, `ism330dl`, `lis2mdl`, `mcp23009e`, `ssd1327`, `steami_config`, `vl53l1x`, `wsen-hids`, `wsen-pads`, `steami_screen` - Domain scopes: `ci`, `docs`, `style`, `tests`, `tooling` ### Examples diff --git a/commitlint.config.js b/commitlint.config.js index 3e7ad78..25d7014 100644 --- a/commitlint.config.js +++ b/commitlint.config.js @@ -34,6 +34,7 @@ module.exports = { 'style', 'tests', 'tooling', + 'steami_screen' ], ], 'type-enum': [ diff --git a/lib/steami_config/examples/calibrate_magnetometer.py b/lib/steami_config/examples/calibrate_magnetometer.py index d217276..cfcc933 100644 --- a/lib/steami_config/examples/calibrate_magnetometer.py +++ b/lib/steami_config/examples/calibrate_magnetometer.py @@ -1,140 +1,150 @@ -"""Calibrate the LIS2MDL magnetometer and save to persistent config. - -This example runs a 3D min/max calibration by collecting samples while -the user rotates the board in all directions. The computed hard-iron -offsets and soft-iron scale factors are stored in the config zone and -survive power cycles. - -Instructions and a countdown are displayed on the SSD1327 OLED screen. -Press MENU to start the calibration. -""" +"""Calibrate the LIS2MDL magnetometer using steami_screen UI.""" import gc from time import sleep_ms +import ssd1327 from daplink_bridge import DaplinkBridge from lis2mdl import LIS2MDL from machine import I2C, SPI, Pin -from ssd1327 import WS_OLED_128X128_SPI from steami_config import SteamiConfig +from steami_screen import Screen, SSD1327Display # --- Hardware init --- i2c = I2C(1) -oled = WS_OLED_128X128_SPI( - SPI(1), - Pin("DATA_COMMAND_DISPLAY"), - Pin("RST_DISPLAY"), - Pin("CS_DISPLAY"), + +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) + btn_menu = Pin("MENU_BUTTON", Pin.IN, Pin.PULL_UP) bridge = DaplinkBridge(i2c) config = SteamiConfig(bridge) config.load() + mag = LIS2MDL(i2c) config.apply_magnetometer_calibration(mag) -# --- Helper functions --- - - -def show(lines): - """Display centered text lines on the round OLED screen.""" - oled.fill(0) - th = len(lines) * 12 - ys = max(0, (128 - th) // 2) - for i, line in enumerate(lines): - x = max(0, (128 - len(line) * 8) // 2) - oled.text(line, x, ys + i * 12, 15) - oled.show() - - -def draw_degree(x, y, col=15): - """Draw a tiny degree symbol (3x3 circle) at pixel position.""" - oled.pixel(x + 1, y, col) - oled.pixel(x, y + 1, col) - oled.pixel(x + 2, y + 1, col) - oled.pixel(x + 1, y + 2, col) - +# --- Helpers --- def wait_menu(): - """Wait for MENU button press then release.""" while btn_menu.value() == 1: sleep_ms(10) while btn_menu.value() == 0: sleep_ms(10) -# --- Step 1: Display instructions and wait for MENU --- +def show_intro(): + screen.clear() + screen.title("COMPAS") + + screen.text("Tournez la", at=(22, 38)) + screen.text("carte dans", at=(22, 50)) + screen.text("toutes les", at=(22, 62)) + screen.text("directions", at=(24, 74)) + screen.text("Menu=demarrer", at=(10, 90)) + + screen.show() + + +def show_progress(remaining): + screen.clear() + screen.title("COMPAS") + + screen.text("Acquisition...", at=(12, 44)) + screen.value(remaining, at=(30, 60)) + screen.text("Tournez", at=(30, 80)) + screen.text("la carte", at=(28, 92)) + + screen.show() + + +def show_message(*lines): + screen.clear() + screen.title("COMPAS") + screen.subtitle(*lines) + screen.show() + + +def show_results(readings): + screen.clear() + screen.title("COMPAS") + + screen.text("Resultats:", at=(24, 34)) + + y = 48 + for i, heading in enumerate(readings): + line = "{}: {} deg".format(i + 1, int(heading)) + screen.text(line, at=(16, y)) + y += 12 + + screen.text("Termine !", at=(28, 112)) + screen.show() + + +# --- Step 1: Instructions --- print("=== Magnetometer Calibration ===\n") -print("Current offsets: x={:.1f} y={:.1f} z={:.1f}".format( - mag.x_off, mag.y_off, mag.z_off)) -print("Current scales: x={:.3f} y={:.3f} z={:.3f}\n".format( - mag.x_scale, mag.y_scale, mag.z_scale)) - -show([ - "COMPAS", - "", - "Tournez la", - "carte dans", - "toutes les", - "directions", - "", - "MENU = demarrer", -]) + +show_intro() print("Press MENU to start calibration...") wait_menu() print("Starting calibration...\n") -# --- Step 2: Acquisition with countdown --- + +# --- Step 2: Acquisition --- samples = 600 delay = 20 total_sec = (samples * delay) // 1000 + xmin = ymin = zmin = 1e9 xmax = ymax = zmax = -1e9 for s in range(samples): x, y, z = mag.magnetic_field() + xmin = min(xmin, x) xmax = max(xmax, x) ymin = min(ymin, y) ymax = max(ymax, y) zmin = min(zmin, z) zmax = max(zmax, z) + if s % 50 == 0: - remain = total_sec - (s * delay) // 1000 - show([ - "COMPAS", - "", - "Acquisition...", - "", - "Continuez a", - "tourner", - "", - "{} sec".format(remain), - ]) + remaining = total_sec - (s * delay) // 1000 + show_progress(remaining) + sleep_ms(delay) + +# --- Compute calibration --- + mag.x_off = (xmax + xmin) / 2.0 mag.y_off = (ymax + ymin) / 2.0 mag.z_off = (zmax + zmin) / 2.0 + mag.x_scale = (xmax - xmin) / 2.0 or 1.0 mag.y_scale = (ymax - ymin) / 2.0 or 1.0 mag.z_scale = (zmax - zmin) / 2.0 or 1.0 print("Calibration complete!") -print(" Hard-iron offsets: x={:.1f} y={:.1f} z={:.1f}".format( - mag.x_off, mag.y_off, mag.z_off)) -print(" Soft-iron scales: x={:.3f} y={:.3f} z={:.3f}\n".format( - mag.x_scale, mag.y_scale, mag.z_scale)) -# --- Step 3: Save to config zone --- -show(["COMPAS", "", "Sauvegarde..."]) +# --- Step 3: Save --- + +show_message("Sauvegarde...") config.set_magnetometer_calibration( hard_iron_x=mag.x_off, @@ -144,42 +154,42 @@ def wait_menu(): soft_iron_y=mag.y_scale, soft_iron_z=mag.z_scale, ) + config.save() -print("Calibration saved to config zone.\n") sleep_ms(500) + # --- Step 4: Verify --- -show(["COMPAS", "", "Sauvegarde OK", "", "Verification..."]) +show_message("Sauvegarde OK", "", "Verification...") gc.collect() + config2 = SteamiConfig(bridge) config2.load() mag2 = LIS2MDL(i2c) config2.apply_magnetometer_calibration(mag2) -print("Verification (5 heading readings after reload):") -result_lines = ["COMPAS", "", "Resultats:"] +print("Verification (5 readings):") + +readings = [] + for i in range(5): heading = mag2.heading_flat_only() - line = " {}: cap={:.0f}".format(i + 1, heading) - print(" Reading {}: heading={:.1f} deg".format(i + 1, heading)) - result_lines.append(line) + readings.append(heading) + + screen.clear() + screen.title("COMPAS") + screen.value(int(heading), unit="deg", label="Mesure {}".format(i + 1)) + screen.show() + + print("Reading {}: {:.1f} deg".format(i + 1, heading)) sleep_ms(500) -result_lines.append("") -result_lines.append("Termine !") - -# Draw results with degree symbols -oled.fill(0) -th = len(result_lines) * 12 -ys = max(0, (128 - th) // 2) -for i, line in enumerate(result_lines): - x = max(0, (128 - len(line) * 8) // 2) - oled.text(line, x, ys + i * 12, 15) - if "cap=" in line: - draw_degree(x + len(line) * 8 + 1, ys + i * 12) -oled.show() - -print("\nDone! Calibration is stored and will be restored at next boot.") + +# --- Done --- + +show_results(readings) + +print("\nDone! Calibration stored.") diff --git a/lib/steami_screen/README.md b/lib/steami_screen/README.md new file mode 100644 index 0000000..4b32f2d --- /dev/null +++ b/lib/steami_screen/README.md @@ -0,0 +1,298 @@ +# STeaMi Screen MicroPython Driver + +High-level UI and drawing library for STeaMi displays. + +This driver provides a **device-agnostic abstraction layer** on top of display drivers +(SSD1327, GC9A01, etc.) and exposes a **simple API to draw UI elements** such as: + +- text layouts +- widgets (bars, gauges, graphs) +- menus +- icons and faces + +It is designed to make building **embedded UIs fast and consistent**. + +--- + +## Features + +* Display abstraction (works with multiple backends) +* Automatic layout helpers (center, north, etc.) +* Text rendering with alignment and scaling +* Basic drawing primitives (pixel, line, rect, circle) +* UI components: + * title / subtitle / value blocks + * progress bar + * menu rendering + * graph plotting + * gauge display + * compass + * watch (clock UI) + * face / icon rendering +* Backend delegation (compatible with FrameBuffer-based drivers) +* Mock-testable architecture (no hardware required) + +--- + +## Supported Backends + +The driver works with any display exposing a FrameBuffer-like API: + +- `fill()` +- `pixel()` +- `line()` +- `rect()` / `fill_rect()` +- `text()` +- `show()` + +Tested with: + +- SSD1327 (OLED 128x128) :contentReference[oaicite:0]{index=0} +- GC9A01 (round TFT) + +--- + +## Basic Usage + +```python +from machine import I2C +from ssd1327 import WS_OLED_128X128_I2C +from steami_screen import Screen + +i2c = I2C(1) +display = WS_OLED_128X128_I2C(i2c) + +screen = Screen(display) + +screen.clear() +screen.title("STeaMi") +screen.value(42, label="Temp", unit="C") +screen.show() +``` + +--- + +## API Reference + +## Initialization + +```python +screen = Screen(display) +``` + +* `display`: backend display driver (SSD1327, GC9A01, etc.) + +--- + +## Core Methods + +### Clear screen + +```python +screen.clear() +``` + +Fill the screen with background color. + +--- + +### Update display + +```python +screen.show() +``` + +Push buffer to display. + +--- + +## Drawing Primitives + +### Pixel + +```python +screen.pixel(x, y, color) +``` + +--- + +### Line + +```python +screen.line(x1, y1, x2, y2, color) +``` + +--- + +### Rectangle + +```python +screen.rect(x, y, w, h, color) +screen.rect(x, y, w, h, color, fill=True) +``` + +--- + +### Circle + +```python +screen.circle(x, y, r, color) +screen.circle(x, y, r, color, fill=True) +``` + +--- + +## Text Rendering + +### Basic text + +```python +screen.text("Hello", x, y) +``` + +--- + +### Positioned text + +```python +screen.text("Centered", position="CENTER") +``` + +Supported positions: + +* `"CENTER"` +* `"NORTH"` +* `"SOUTH"` +* `"EAST"` +* `"WEST"` + +Invalid values fallback to center. + +--- + +### Scaled text + +```python +screen.text("Big", x, y, scale=2) +``` + +--- + +## Layout Helpers + +### Title + +```python +screen.title("Title") +``` + +Draws text near the top (NORTH). + +--- + +### Subtitle + +```python +screen.subtitle(["Line 1", "Line 2"]) +``` + +Supports multiple lines. + +--- + +### Value + +```python +screen.value(23.5, label="Temp", unit="C") +``` + +Displays a main value with optional label and unit. + +--- + +## Widgets + +### Progress Bar + +```python +screen.bar(value=75, max_value=100) +``` + +--- + +### Menu + +```python +screen.menu(["Item 1", "Item 2", "Item 3"], selected=1) +``` + +--- + +### Face / Icon + +```python +screen.face("happy") +``` + +Supports predefined expressions or custom bitmaps. + +--- + +### Graph + +```python +screen.graph([10, 20, 15, 30]) +``` + +Draws a simple line graph. + +--- + +### Gauge + +```python +screen.gauge(value=60, min_value=0, max_value=100) +``` + +--- + +### Compass + +```python +screen.compass(angle=45) +``` + +Displays a compass with direction labels. + +--- + +### Watch (Clock) + +```python +screen.watch(hours=10, minutes=30) +``` + +Draws an analog-style clock. + +--- + +## Examples + +| Example | Description | +| ------------- | -------------------------- | +| `watch.py` | Analog clock display | + +Run with: + +```bash +mpremote mount lib/steami_screen run lib/steami_screen/examples/watch.py +``` + +--- + +## Design Notes + +* The driver **does not depend on a specific display** +* All drawing is delegated to the backend (`display`) +* Layout logic is handled in `Screen` +* Optimized for **readability over raw performance** diff --git a/lib/steami_screen/examples/watch.py b/lib/steami_screen/examples/watch.py new file mode 100644 index 0000000..51787cc --- /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/manifest.py b/lib/steami_screen/manifest.py new file mode 100644 index 0000000..596322f --- /dev/null +++ b/lib/steami_screen/manifest.py @@ -0,0 +1,6 @@ +metadata( + description="Library for controlling the STeaMi round display.", + version="0.0.1", +) + +package("steami_screen") diff --git a/lib/steami_screen/steami_screen/__init__.py b/lib/steami_screen/steami_screen/__init__.py new file mode 100644 index 0000000..cc00e3e --- /dev/null +++ b/lib/steami_screen/steami_screen/__init__.py @@ -0,0 +1,33 @@ +from steami_screen.colors import rgb_to_gray4, rgb_to_rgb8, rgb_to_rgb565 +from steami_screen.device import ( + BLACK, + BLUE, + DARK, + GRAY, + GREEN, + LIGHT, + RED, + WHITE, + YELLOW, + Screen, +) +from steami_screen.gc9a01 import GC9A01Display +from steami_screen.sssd1327 import SSD1327Display + +__all__ = [ + "BLACK", + "BLUE", + "DARK", + "GRAY", + "GREEN", + "LIGHT", + "RED", + "WHITE", + "YELLOW", + "GC9A01Display", + "SSD1327Display", + "Screen", + "rgb_to_gray4", + "rgb_to_rgb8", + "rgb_to_rgb565", +] diff --git a/lib/steami_screen/steami_screen/colors.py b/lib/steami_screen/steami_screen/colors.py new file mode 100644 index 0000000..c13d000 --- /dev/null +++ b/lib/steami_screen/steami_screen/colors.py @@ -0,0 +1,49 @@ +""" +Color conversion utilities for STeaMi display backends. + +Colors are represented as RGB tuples (r, g, b) with values 0-255. +Each backend converts to its native format: + - SSD1327 : grayscale 4-bit (0-15) + - GC9A01 : RGB565 (16-bit) + - Simulator: RGB tuple (pass-through) + +All functions accept legacy int values for backward compatibility. +""" + + +def rgb_to_gray4(color): + """Convert an RGB tuple to a 4-bit grayscale value (0-15). + + Uses BT.601 luminance: Y = 0.299*R + 0.587*G + 0.114*B + Accepts int for backward compatibility (returned as-is, clamped to 0-15). + """ + if isinstance(color, int): + return max(0, min(15, color)) + r, g, b = color + luminance = (r * 77 + g * 150 + b * 29) >> 8 # 0-255 + return luminance >> 4 # 0-15 + + +def rgb_to_rgb565(color): + """Convert an RGB tuple to a 16-bit RGB565 integer. + + Accepts int for backward compatibility (treated as gray4, expanded). + """ + if isinstance(color, int): + g = max(0, min(15, color)) * 17 # 0-255 + r, b = g, g + else: + r, g, b = color + return ((r & 0xF8) << 8) | ((g & 0xFC) << 3) | (b >> 3) + + +def rgb_to_rgb8(color): + """Convert a color to an RGB tuple (r, g, b). + + If already a tuple, returns it unchanged. + Accepts int for backward compatibility (treated as gray4, expanded). + """ + if isinstance(color, int): + v = max(0, min(15, color)) * 17 # 0-255 + return (v, v, v) + return color diff --git a/lib/steami_screen/steami_screen/device.py b/lib/steami_screen/steami_screen/device.py new file mode 100644 index 0000000..a1d68c2 --- /dev/null +++ b/lib/steami_screen/steami_screen/device.py @@ -0,0 +1,663 @@ +""" +steami_screen — High-level API for the STeaMi round display. + +Works with any backend that implements the display interface: + - SSD1327 (128x128 grayscale OLED) + - GC9A01 (240x240 RGB TFT) + - SimBackend (Pillow, for screenshots and validation) + +3-level API: + Level 1 — Widgets: title(), value(), subtitle(), bar(), gauge(), face(), compass()... + Level 2 — Cardinal: text("hello", at="NE"), line(...), circle(...) + Level 3 — Pixels: pixel(x, y, color) with screen.center, screen.radius +""" + +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 +GRAY = (153, 153, 153) # gray4=9 +LIGHT = (187, 187, 187) # gray4=11 +WHITE = (255, 255, 255) # gray4=15 + +# Accent colors (used on color displays, degrade gracefully to gray on SSD1327) +GREEN = (119, 255, 119) +RED = (255, 85, 85) +BLUE = (85, 85, 255) +YELLOW = (255, 255, 85) + +# --- Pixel-art face bitmaps (8x8, MSB = left) --- +FACES = { + "happy": (0x00, 0x24, 0x24, 0x00, 0x00, 0x42, 0x3C, 0x00), + "sad": (0x00, 0x24, 0x24, 0x00, 0x00, 0x3C, 0x42, 0x00), + "surprised": (0x00, 0x24, 0x24, 0x00, 0x18, 0x24, 0x24, 0x18), + "sleeping": (0x00, 0x00, 0x66, 0x00, 0x00, 0x18, 0x18, 0x00), + "angry": (0x00, 0x42, 0x24, 0x24, 0x00, 0x3C, 0x42, 0x00), + "love": (0x00, 0x66, 0xFF, 0xFF, 0x7E, 0x3C, 0x18, 0x00), +} + +# --- Cardinal position names --- + +_CARDINALS = ("N", "NE", "E", "SE", "S", "SW", "W", "NW", "CENTER") + + +class Screen: + """High-level wrapper around a raw display backend.""" + + CHAR_W = 8 # framebuf built-in font width + CHAR_H = 8 # framebuf built-in font height + + def __init__(self, display, width=128, height=128): + self._d = display + self.width = width + self.height = height + + # --- Adaptive properties --- + + @property + def center(self): + return (self.width // 2, self.height // 2) + + @property + def radius(self): + return min(self.width, self.height) // 2 + + @property + def max_chars(self): + return self.width // self.CHAR_W + + # --- Cardinal position resolver --- + + def _safe_margin(self, tw, from_edge): + """Compute the minimum Y margin from top/bottom so that text of + width `tw` fits inside the circle. `from_edge` is the baseline + distance from the circle edge.""" + r = self.radius + # At distance d from center, available width = 2*sqrt(r^2 - d^2) + # We need 2*sqrt(r^2 - d^2) >= tw, so d <= sqrt(r^2 - (tw/2)^2) + half_tw = tw / 2 + if half_tw >= r: + return r # text too wide, push to center + max_d = math.sqrt(r * r - half_tw * half_tw) + # margin from top = cy - max_d = r - max_d + min_margin = r - int(max_d) + return max(min_margin + 2, from_edge) # +2px padding + + def _resolve(self, at, text_len=0, scale=1): + """Return (x, y) for a cardinal position, centering text if needed.""" + cx, cy = self.center + ch = self.CHAR_H * scale + tw = text_len * self.CHAR_W * scale # total text pixel width + + # Margins adapted to circular screen + # Floor at ch*2+4 ensures titles stay at a consistent height + margin_ns = self._safe_margin(tw, ch * 2 + 4) # N/S + margin_ew = ch + 4 # E/W: fixed side margin + + positions = { + "N": (cx - tw // 2, margin_ns), + "NE": (self.width - margin_ew - tw, margin_ns), + "E": (self.width - margin_ew - tw, cy - ch // 2), + "SE": (self.width - margin_ew - tw, self.height - margin_ns - ch), + "S": (cx - tw // 2, self.height - margin_ns - ch), + "SW": (margin_ew, self.height - margin_ns - ch), + "W": (margin_ew, cy - ch // 2), + "NW": (margin_ew, margin_ns), + "CENTER": (cx - tw // 2, cy - ch // 2), + } + return positions.get(at, positions["CENTER"]) + + # --- Level 1: Widgets --- + + def title(self, text, color=GRAY): + """Draw title text at the top (N).""" + x, y = self._resolve("N", len(text)) + self._d.text(text, x, y, color) + + def value(self, val, unit=None, at="CENTER", label=None, + color=WHITE, scale=2, y_offset=0): + """Draw a large value, optionally with unit below and label above.""" + text = str(val) + cx, cy = self.center + char_w = self.CHAR_W * scale + char_h = self.CHAR_H * scale + tw = len(text) * char_w + + # Compute vertical position: center the value+unit block + if unit: + gap = char_h // 3 + unit_h = self.CHAR_H + block_h = char_h + gap + unit_h + vy = cy - block_h // 2 + else: + vy = cy - char_h // 2 + + if at == "CENTER": + x = cx - tw // 2 + y = vy + y_offset + elif at == "W": + x = self.width // 4 - tw // 2 + y = vy + elif at == "E": + x = 3 * self.width // 4 - tw // 2 + y = vy + else: + x, y = self._resolve(at, len(text), scale) + + # Optional label above + if label: + lx = x + tw // 2 - len(label) * self.CHAR_W // 2 + self._d.text(label, lx, y - self.CHAR_H - 4, GRAY) + + # Value (large) + self._draw_scaled_text(text, x, y, color, scale) + + # Optional unit below (medium font if backend supports it) + if unit: + unit_y = y + char_h + char_h // 3 + ux = x + tw // 2 - len(unit) * self.CHAR_W // 2 + if hasattr(self._d, 'draw_medium_text'): + self._d.draw_medium_text(unit, ux, unit_y, LIGHT) + else: + self._d.text(unit, ux, unit_y, LIGHT) + + def subtitle(self, *lines, color=DARK): + """Draw subtitle text at the bottom (S). Accepts multiple lines.""" + if not lines: + return + max_len = max(len(line) for line in lines) + _, base_y = self._resolve("S", max_len) + line_h = self.CHAR_H + 3 + n = len(lines) + + if n == 1: + start_y = base_y + self.CHAR_H + else: + block_h = (n - 1) * line_h + start_y = base_y - block_h // 2 + + 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 + draw(line, x, y, color) + + def bar(self, val, max_val=100, y_offset=0, color=LIGHT): + """Draw a horizontal progress bar below center.""" + cx, cy = self.center + bar_w = self.width - 40 + bar_h = 8 + bx = cx - bar_w // 2 + by = cy + 20 + y_offset + fill_w = int(bar_w * min(val, max_val) / max_val) + + # Background + self._fill_rect(bx, by, bar_w, bar_h, DARK) + # Fill + if fill_w > 0: + self._fill_rect(bx, by, fill_w, bar_h, color) + + def gauge(self, val, min_val=0, max_val=100, unit=None, color=LIGHT): + """Draw a circular arc gauge (270 deg, gap at bottom). + + The arc is drawn close to the screen border. Call gauge() before + title() so that text layers on top of the arc. + """ + cx, cy = self.center + arc_w = max(5, self.radius // 9) + r = self.radius - arc_w // 2 - 1 + start_angle = 135 + sweep = 270 + ratio = (val - min_val) / (max_val - min_val) + ratio = max(0.0, min(1.0, ratio)) + + # Background arc + self._draw_arc(cx, cy, r, start_angle, sweep, DARK, arc_w) + # Filled arc + if ratio > 0: + self._draw_arc(cx, cy, r, start_angle, int(sweep * ratio), + color, arc_w) + + # Value + unit centered as a block + text = str(val) + char_h = self.CHAR_H * 2 # scale=2 + tw = len(text) * self.CHAR_W * 2 + if unit: + gap = char_h // 3 + unit_h = self.CHAR_H + block_h = char_h + gap + unit_h + vy = cy - block_h // 2 + else: + vy = cy - char_h // 2 + vx = cx - tw // 2 + self._draw_scaled_text(text, vx, vy, WHITE, 2) + if unit: + ux = cx - len(unit) * self.CHAR_W // 2 + uy = vy + char_h + gap + if hasattr(self._d, 'draw_medium_text'): + self._d.draw_medium_text(unit, ux, uy, LIGHT) + else: + self._d.text(unit, ux, uy, LIGHT) + + # Min/max labels at arc endpoints (slightly inward to stay visible) + min_t = str(int(min_val)) + max_t = str(int(max_val)) + r_label = r - arc_w - 10 + # Nudge angles inward (toward bottom center) so labels stay on screen + angle_s = math.radians(start_angle + 8) + angle_e = math.radians(start_angle + sweep - 8) + lx = int(cx + r_label * math.cos(angle_s)) - len(min_t) * self.CHAR_W // 2 + 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._d.text) + draw_sm(min_t, lx, ly, GRAY) + draw_sm(max_t, rx, ry, GRAY) + + def graph(self, data, min_val=0, max_val=100, color=LIGHT): + """Draw a scrolling line graph with the current value above. + + The last data point is displayed as a large value above the + graph area. Call title() before graph() for proper layout. + """ + cx, _cy = self.center + margin = 15 + gx = margin + 6 + gy = 38 + gw = self.width - margin - gx + gh = 52 + + # Current value just below title area (fixed position) + if data: + text = str(int(data[-1])) + draw_fn = getattr(self._d, 'draw_medium_text', + self._d.text) + tw = len(text) * self.CHAR_W + vx = cx - tw // 2 + vy = 31 + draw_fn(text, vx, vy, WHITE) + + # Y-axis labels (max, mid, min) + def _fmt(v): + if v >= 1000 and v % 1000 == 0: + return str(int(v // 1000)) + "k" + return str(int(v)) + + 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), + (min_val, gy + gh)]: + label = _fmt(val) + cw = int(self.CHAR_W * 0.85) + lx = gx - len(label) * cw - 1 + ly = yp - self.CHAR_H // 2 + draw_sm(label, lx, ly, DARK) + + # Dashed grid line at midpoint + mid_y = gy + gh // 2 + dash, gap = 3, 3 + x = gx + 1 + while x < gx + gw: + x2 = min(x + dash - 1, gx + gw - 1) + self._d.line(x, mid_y, x2, mid_y, (51, 51, 51)) + x += dash + gap + + # Y axis (extend +1 to meet X axis corner) + self._vline(gx, gy, gh + 1, DARK) + # X axis + self._hline(gx, gy + gh, gw, DARK) + + if len(data) < 2: + return + + # Map data points to graph area + step = gw / (len(data) - 1) if len(data) > 1 else gw + span = max_val - min_val + if span == 0: + span = 1 + + prev_px, prev_py = None, None + for i, v in enumerate(data): + px = int(gx + i * step) + ratio = (v - min_val) / span + ratio = max(0.0, min(1.0, ratio)) + py = int(gy + gh - ratio * gh) + if prev_px is not None: + self._line(prev_px, prev_py, px, py, color) + prev_px, prev_py = px, py + + def menu(self, items, selected=0, color=WHITE): + """Draw a scrollable list menu.""" + _cx, _cy = self.center + item_h = self.CHAR_H + 6 + visible = min(len(items), (self.height - 40) // item_h) + + # Scroll window + start = max(0, min(selected - visible // 2, len(items) - visible)) + y = 35 + + for i in range(start, min(start + visible, len(items))): + iy = y + (i - start) * item_h + if i == selected: + self._fill_rect(15, iy - 2, self.width - 30, item_h, DARK) + self._d.text("> " + items[i], 18, iy, color) + else: + self._d.text(" " + items[i], 18, iy, GRAY) + + def compass(self, heading, color=LIGHT): + """Draw a compass with a rotating needle.""" + cx, cy = self.center + r = self.radius - 12 + + # Rose circles + self._draw_circle(cx, cy, r, DARK) + self._draw_circle(cx, cy, int(r * 0.7), DARK) + + # Cardinal labels + for label, angle in (("N", 0), ("E", 90), ("S", 180), ("W", 270)): + 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._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): + inner = r - 6 + outer = r + rad = math.radians(angle) + x1 = cx + int(inner * math.sin(rad)) + y1 = cy - int(inner * math.cos(rad)) + x2 = cx + int(outer * math.sin(rad)) + y2 = cy - int(outer * math.cos(rad)) + c = LIGHT if angle % 90 == 0 else DARK + self._line(x1, y1, x2, y2, c) + + # Needle + rad = math.radians(heading) + needle_len = int(r * 0.85) + half_w = 3 + + # Tip (north side of needle, bright) + nx = cx + int(needle_len * math.sin(rad)) + ny = cy - int(needle_len * math.cos(rad)) + # Tail (south side, dark) + sx = cx - int(needle_len * math.sin(rad)) + sy = cy + int(needle_len * math.cos(rad)) + # Perpendicular offset for width + px = int(half_w * math.cos(rad)) + py = int(half_w * math.sin(rad)) + + # North half (bright) + self._fill_triangle(nx, ny, cx - px, cy - py, cx + px, cy + py, color) + # South half (dark) + self._fill_triangle(sx, sy, cx - px, cy - py, cx + px, cy + py, DARK) + + # Center pivot + self._fill_circle(cx, cy, 3, GRAY) + + def watch(self, hours, minutes, seconds=0, color=LIGHT): + """Draw an analog watch face.""" + cx, cy = self.center + r = self.radius - 8 + + # Clock face circle + self._draw_circle(cx, cy, r, DARK) + + # 12 hour tick marks + for i in range(12): + angle = i * 30 + rad = math.radians(angle) + if i % 3 == 0: + inner = r - 8 + c = LIGHT + else: + inner = r - 5 + c = GRAY + x1 = cx + int(inner * math.sin(rad)) + y1 = cy - int(inner * math.cos(rad)) + x2 = cx + int(r * math.sin(rad)) + y2 = cy - int(r * math.cos(rad)) + self._line(x1, y1, x2, y2, c) + + # Cardinal numbers: 12, 3, 6, 9 + for num, angle in ((12, 0), (3, 90), (6, 180), (9, 270)): + text = str(num) + rad = math.radians(angle) + lx = cx + int((r - 15) * math.sin(rad)) + ly = cy - int((r - 15) * math.cos(rad)) + tw = len(text) * self.CHAR_W + self._d.text(text, lx - tw // 2, ly - self.CHAR_H // 2, WHITE) + + # Hour hand (short, thick) + h_angle = (hours % 12 + minutes / 60) * 30 + h_rad = math.radians(h_angle) + h_len = int(r * 0.50) + h_w = 3 + hx = cx + int(h_len * math.sin(h_rad)) + hy = cy - int(h_len * math.cos(h_rad)) + px = int(h_w * math.cos(h_rad)) + py = int(h_w * math.sin(h_rad)) + self._fill_triangle(hx, hy, cx - px, cy - py, cx + px, cy + py, color) + + # Minute hand (longer, thinner) + m_angle = (minutes + seconds / 60) * 6 + m_rad = math.radians(m_angle) + m_len = int(r * 0.75) + m_w = 2 + mx = cx + int(m_len * math.sin(m_rad)) + my = cy - int(m_len * math.cos(m_rad)) + px = int(m_w * math.cos(m_rad)) + py = int(m_w * math.sin(m_rad)) + self._fill_triangle(mx, my, cx - px, cy - py, cx + px, cy + py, color) + + # Second hand (thin line) + s_angle = seconds * 6 + s_rad = math.radians(s_angle) + s_len = int(r * 0.85) + sx = cx + int(s_len * math.sin(s_rad)) + sy = cy - int(s_len * math.cos(s_rad)) + self._line(cx, cy, sx, sy, GRAY) + + # Center pivot + self._fill_circle(cx, cy, 3, GRAY) + + def face(self, expression, compact=False, color=LIGHT): + """Draw a pixel-art face expression (8x8 bitmap scaled up). + + Args: + expression: Name ("happy", "sad", "surprised", "sleeping", + "angry", "love") or tuple of 8 ints (custom). + compact: If True, smaller scale leaving room for title/subtitle. + color: Color for lit pixels. + """ + if isinstance(expression, str): + bitmap = FACES.get(expression) + if bitmap is None: + return + else: + bitmap = expression + + cx, cy = self.center + if compact: + scale = self.width // 16 # 8 on 128px + ox = cx - 4 * scale + oy = cy - 4 * scale - scale // 2 + else: + scale = (self.width * 11) // 128 # 11 on 128px + ox = cx - 4 * scale + oy = cy - 4 * scale + + for row in range(8): + byte = bitmap[row] + for col in range(8): + if byte & (0x80 >> col): + self._fill_rect(ox + col * scale, oy + row * scale, + scale, scale, color) + + # --- Level 2: Cardinal text & shapes --- + + def text(self, text, at="CENTER", color=WHITE, scale=1): + """Draw text at a cardinal position or explicit (x,y).""" + if isinstance(at, str): + x, y = self._resolve(at, len(text), scale) + else: + x, y = at + if scale > 1: + self._draw_scaled_text(text, x, y, color, scale) + else: + self._d.text(text, x, y, color) + + def line(self, x1, y1, x2, y2, color=WHITE): + self._line(x1, y1, x2, y2, color) + + def circle(self, x, y, r, color=WHITE, fill=False): + if fill: + self._fill_circle(x, y, r, color) + else: + self._draw_circle(x, y, r, color) + + def rect(self, x, y, w, h, color=WHITE, fill=False): + if fill: + self._fill_rect(x, y, w, h, color) + else: + self._rect(x, y, w, h, color) + + def pixel(self, x, y, color=WHITE): + self._d.pixel(x, y, color) + + # --- Control --- + + def clear(self, color=BLACK): + self._d.fill(color) + + def show(self): + self._d.show() + + # --- Internal drawing helpers --- + + def _line(self, x1, y1, x2, y2, 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, c) + + def _vline(self, x, y, h, c): + self._d.line(x, y, x, y + h - 1, c) + + def _fill_rect(self, x, y, w, h, c): + if hasattr(self._d, 'fill_rect'): + self._d.fill_rect(x, y, w, h, c) + elif hasattr(self._d, 'framebuf'): + self._d.framebuf.fill_rect(x, y, w, h, rgb_to_gray4(c)) + else: + for row in range(h): + self._d.line(x, y + row, x + w - 1, y + row, c) + + def _rect(self, x, y, w, h, c): + if hasattr(self._d, 'rect'): + self._d.rect(x, y, w, h, c) + elif hasattr(self._d, 'framebuf'): + self._d.framebuf.rect(x, y, w, h, rgb_to_gray4(c)) + else: + self._hline(x, y, w, c) + self._hline(x, y + h - 1, w, c) + self._vline(x, y, h, c) + self._vline(x + w - 1, y, h, c) + + def _draw_scaled_text(self, text, x, y, color, scale): + """Draw text at scale > 1 by scaling each pixel of the 8x8 font.""" + # Use framebuf built-in if available (MicroPython does not support scale) + # Fallback: draw each char pixel-by-pixel at scale + if hasattr(self._d, 'draw_scaled_text'): + self._d.draw_scaled_text(text, x, y, color, scale) + return + # 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._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._d.text(text, x + dx, y + dy, color) + elif scale == 3: + for dx in range(3): + for dy in range(3): + self._d.text(text, x + dx, y + dy, color) + else: + 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.""" + if hasattr(self._d, 'draw_arc'): + self._d.draw_arc(cx, cy, r, start_deg, sweep_deg, color, width) + return + steps = max(sweep_deg, 60) + half_w = width // 2 + for i in range(steps + 1): + angle = math.radians(start_deg + i * sweep_deg / steps) + for dr in range(-half_w, half_w + 1): + 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._d.pixel(x, y, color) + + def _draw_circle(self, cx, cy, r, color): + """Bresenham circle.""" + x, y, d = r, 0, 1 - r + while x >= y: + for sx, sy in ((x, y), (y, x), (-x, y), (-y, x), + (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._d.pixel(px, py, color) + y += 1 + if d < 0: + d += 2 * y + 1 + else: + x -= 1 + d += 2 * (y - x) + 1 + + def _fill_circle(self, cx, cy, r, color): + """Filled circle using horizontal lines.""" + for dy in range(-r, r + 1): + dx = int(math.sqrt(r * r - dy * dy)) + y = cy + dy + if 0 <= y < self.height: + x1 = max(0, cx - dx) + x2 = min(self.width - 1, cx + dx) + self._d.line(x1, y, x2, y, color) + + def _fill_triangle(self, x0, y0, x1, y1, x2, y2, color): + """Filled triangle using scanline.""" + # Sort by y + pts = sorted([(x0, y0), (x1, y1), (x2, y2)], key=lambda p: p[1]) + (ax, ay), (bx, by), (cx, cy_) = pts + + def interp(ya, xa, yb, xb, y): + if yb == ya: + return xa + return xa + (xb - xa) * (y - ya) // (yb - ya) + + for y in range(ay, cy_ + 1): + if y < by: + xl = interp(ay, ax, cy_, cx, y) + xr = interp(ay, ax, by, bx, y) + else: + xl = interp(ay, ax, cy_, cx, y) + xr = interp(by, bx, cy_, cx, y) + if xl > xr: + xl, xr = xr, xl + if 0 <= y < self.height: + x_start = max(0, xl) + x_end = min(self.width - 1, xr) + if x_start <= x_end: + self._d.line(x_start, y, x_end, y, color) diff --git a/lib/steami_screen/steami_screen/gc9a01.py b/lib/steami_screen/steami_screen/gc9a01.py new file mode 100644 index 0000000..d2bd9a4 --- /dev/null +++ b/lib/steami_screen/steami_screen/gc9a01.py @@ -0,0 +1,44 @@ +""" +GC9A01 display wrapper — converts RGB colors to RGB565. + +Wraps the raw GC9A01 driver so that steami_screen can pass RGB tuples +while the hardware receives 16-bit RGB565 values. + +Usage on the STeaMi board: + import gc9a01 + from steami_gc9a01 import GC9A01Display + raw = gc9a01.GC9A01(spi, dc, cs, rst, ...) + display = GC9A01Display(raw) +""" + +from steami_screen.colors import rgb_to_rgb565 + + +class GC9A01Display: + """Thin wrapper around a GC9A01 driver that accepts RGB colors.""" + + def __init__(self, raw, width=240, height=240): + self._raw = raw + self.width = width + self.height = height + + def fill(self, color): + self._raw.fill(rgb_to_rgb565(color)) + + def pixel(self, x, y, color): + self._raw.pixel(x, y, rgb_to_rgb565(color)) + + def text(self, string, x, y, color): + self._raw.text(string, x, y, rgb_to_rgb565(color)) + + def line(self, x1, y1, x2, y2, color): + self._raw.line(x1, y1, x2, y2, rgb_to_rgb565(color)) + + def fill_rect(self, x, y, w, h, color): + self._raw.fill_rect(x, y, w, h, rgb_to_rgb565(color)) + + def rect(self, x, y, w, h, color): + self._raw.rect(x, y, w, h, rgb_to_rgb565(color)) + + def show(self): + self._raw.show() diff --git a/lib/steami_screen/steami_screen/sssd1327.py b/lib/steami_screen/steami_screen/sssd1327.py new file mode 100644 index 0000000..8609568 --- /dev/null +++ b/lib/steami_screen/steami_screen/sssd1327.py @@ -0,0 +1,44 @@ +""" +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_ssd1327 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): + self._raw.fill_rect(x, y, w, h, rgb_to_gray4(color)) + + def rect(self, x, y, w, h, color): + self._raw.rect(x, y, w, h, rgb_to_gray4(color)) + + def show(self): + self._raw.show() diff --git a/tests/scenarios/steami_screen.yaml b/tests/scenarios/steami_screen.yaml new file mode 100644 index 0000000..559ad44 --- /dev/null +++ b/tests/scenarios/steami_screen.yaml @@ -0,0 +1,382 @@ +driver: steami_screen +driver_class: Screen + +mock_init: | + class FakeDisplay: + def __init__(self): + self.calls = [] + self.width = 128 + self.height = 128 + + def fill(self, color): + self.calls.append(("fill", color)) + + def pixel(self, x, y, color): + self.calls.append(("pixel", x, y, color)) + + def text(self, text, x, y, color): + self.calls.append(("text", text, x, y, color)) + + def line(self, x1, y1, x2, y2, color): + self.calls.append(("line", x1, y1, x2, y2, color)) + + def fill_rect(self, x, y, w, h, color): + self.calls.append(("fill_rect", x, y, w, h, color)) + + def rect(self, x, y, w, h, color): + self.calls.append(("rect", x, y, w, h, color)) + + def show(self): + self.calls.append(("show",)) + + def clear_calls(self): + self.calls = [] + + dev = Screen(FakeDisplay()) + +tests: + - name: "Center is computed correctly" + action: script + script: | + result = dev.center == (64, 64) + expect_true: true + mode: [mock] + + - name: "Radius is computed correctly" + action: script + script: | + result = dev.radius == 64 + expect_true: true + mode: [mock] + + - name: "Max chars matches screen width" + action: script + script: | + result = dev.max_chars == 16 + expect_true: true + mode: [mock] + + - name: "clear calls backend fill" + action: script + script: | + d = dev._d + d.clear_calls() + dev.clear() + result = len(d.calls) == 1 and d.calls[0][0] == "fill" + expect_true: true + mode: [mock] + + - name: "show calls backend show" + action: script + script: | + d = dev._d + d.clear_calls() + dev.show() + result = len(d.calls) == 1 and d.calls[0][0] == "show" + expect_true: true + mode: [mock] + + - name: "pixel calls backend pixel" + action: script + script: | + d = dev._d + d.clear_calls() + dev.pixel(10, 20) + result = d.calls == [("pixel", 10, 20, (255, 255, 255))] + expect_true: true + mode: [mock] + + - name: "line calls backend line" + action: script + script: | + d = dev._d + d.clear_calls() + dev.line(1, 2, 30, 40) + result = d.calls == [("line", 1, 2, 30, 40, (255, 255, 255))] + expect_true: true + mode: [mock] + + - name: "rect outline uses backend rect" + action: script + script: | + d = dev._d + d.clear_calls() + dev.rect(5, 6, 20, 10) + result = d.calls == [("rect", 5, 6, 20, 10, (255, 255, 255))] + expect_true: true + mode: [mock] + + - name: "rect fill uses backend fill_rect" + action: script + script: | + d = dev._d + d.clear_calls() + dev.rect(5, 6, 20, 10, fill=True) + result = d.calls == [("fill_rect", 5, 6, 20, 10, (255, 255, 255))] + expect_true: true + mode: [mock] + + - name: "text at CENTER draws backend text" + action: script + script: | + d = dev._d + d.clear_calls() + dev.text("Hi") + result = len(d.calls) >= 1 and d.calls[0][0] == "text" + expect_true: true + mode: [mock] + + - name: "text at explicit coordinates uses given position" + action: script + script: | + d = dev._d + d.clear_calls() + dev.text("Hi", at=(12, 34)) + result = d.calls == [("text", "Hi", 12, 34, (255, 255, 255))] + expect_true: true + mode: [mock] + + - name: "text scale 2 still renders text" + action: script + script: | + d = dev._d + d.clear_calls() + dev.text("Hi", scale=2) + text_calls = [c for c in d.calls if c[0] == "text"] + result = len(text_calls) > 0 + expect_true: true + mode: [mock] + + - name: "title draws text near north position" + action: script + script: | + d = dev._d + d.clear_calls() + dev.title("Hello") + text_calls = [c for c in d.calls if c[0] == "text"] + result = len(text_calls) == 1 and text_calls[0][1] == "Hello" + expect_true: true + mode: [mock] + + - name: "subtitle with one line draws one text call" + action: script + script: | + d = dev._d + d.clear_calls() + dev.subtitle("Bottom") + text_calls = [c for c in d.calls if c[0] == "text"] + result = len(text_calls) == 1 and text_calls[0][1] == "Bottom" + expect_true: true + mode: [mock] + + - name: "subtitle with multiple lines draws multiple text calls" + action: script + script: | + d = dev._d + d.clear_calls() + dev.subtitle("Line1", "Line2", "Line3") + text_calls = [c for c in d.calls if c[0] == "text"] + labels = [c[1] for c in text_calls] + result = len(text_calls) == 3 and labels == ["Line1", "Line2", "Line3"] + expect_true: true + mode: [mock] + + - name: "subtitle with no lines does nothing" + action: script + script: | + d = dev._d + d.clear_calls() + dev.subtitle() + result = len(d.calls) == 0 + expect_true: true + mode: [mock] + + - name: "value draws main value" + action: script + script: | + d = dev._d + d.clear_calls() + dev.value(42) + text_calls = [c for c in d.calls if c[0] == "text"] + result = len(text_calls) > 0 + expect_true: true + mode: [mock] + + - name: "value with label draws label and value" + action: script + script: | + d = dev._d + d.clear_calls() + dev.value(42, label="Temp") + text_calls = [c for c in d.calls if c[0] == "text"] + labels = [c[1] for c in text_calls] + result = "Temp" in labels + expect_true: true + mode: [mock] + + - name: "value with unit draws extra text" + action: script + script: | + d = dev._d + d.clear_calls() + dev.value(42, unit="C") + text_calls = [c for c in d.calls if c[0] == "text"] + labels = [c[1] for c in text_calls] + result = "C" in labels + expect_true: true + mode: [mock] + + - name: "bar draws background and fill" + action: script + script: | + d = dev._d + d.clear_calls() + dev.bar(50, max_val=100) + fill_calls = [c for c in d.calls if c[0] == "fill_rect"] + result = len(fill_calls) == 2 + expect_true: true + mode: [mock] + + - name: "bar with zero value only draws background" + action: script + script: | + d = dev._d + d.clear_calls() + dev.bar(0, max_val=100) + fill_calls = [c for c in d.calls if c[0] == "fill_rect"] + result = len(fill_calls) == 1 + expect_true: true + mode: [mock] + + - name: "menu draws all visible items" + action: script + script: | + d = dev._d + d.clear_calls() + dev.menu(["A", "B", "C"], selected=1) + text_calls = [c for c in d.calls if c[0] == "text"] + labels = [c[1] for c in text_calls] + result = len(text_calls) == 3 and "> B" in labels + expect_true: true + mode: [mock] + + - name: "face happy draws filled pixels" + action: script + script: | + d = dev._d + d.clear_calls() + dev.face("happy") + fill_calls = [c for c in d.calls if c[0] == "fill_rect"] + result = len(fill_calls) > 0 + expect_true: true + mode: [mock] + + - name: "face custom bitmap draws filled pixels" + action: script + script: | + d = dev._d + d.clear_calls() + dev.face((0xFF, 0x81, 0x81, 0x81, 0x81, 0x81, 0x81, 0xFF)) + fill_calls = [c for c in d.calls if c[0] == "fill_rect"] + result = len(fill_calls) > 0 + expect_true: true + mode: [mock] + + - name: "face unknown expression does nothing" + action: script + script: | + d = dev._d + d.clear_calls() + dev.face("unknown") + result = len(d.calls) == 0 + expect_true: true + mode: [mock] + + - name: "graph with one point draws axes and value without crashing" + action: script + script: | + d = dev._d + d.clear_calls() + dev.graph([12], min_val=0, max_val=100) + result = len(d.calls) > 0 + expect_true: true + mode: [mock] + + - name: "graph with multiple points draws line segments" + action: script + script: | + d = dev._d + d.clear_calls() + dev.graph([10, 20, 15, 30], min_val=0, max_val=40) + line_calls = [c for c in d.calls if c[0] == "line"] + result = len(line_calls) > 0 + expect_true: true + mode: [mock] + + - name: "gauge draws arc and labels" + action: script + script: | + d = dev._d + d.clear_calls() + dev.gauge(50, min_val=0, max_val=100) + pixel_calls = [c for c in d.calls if c[0] == "pixel"] + text_calls = [c for c in d.calls if c[0] == "text"] + result = len(pixel_calls) > 0 and len(text_calls) > 0 + expect_true: true + mode: [mock] + + - name: "compass draws needle and labels" + action: script + script: | + d = dev._d + d.clear_calls() + dev.compass(90) + text_calls = [c for c in d.calls if c[0] == "text"] + line_calls = [c for c in d.calls if c[0] == "line"] + result = len(text_calls) >= 4 and len(line_calls) > 0 + expect_true: true + mode: [mock] + + - name: "watch draws clock face" + action: script + script: | + d = dev._d + d.clear_calls() + dev.watch(10, 15, 30) + text_calls = [c for c in d.calls if c[0] == "text"] + line_calls = [c for c in d.calls if c[0] == "line"] + result = len(text_calls) >= 4 and len(line_calls) > 0 + expect_true: true + mode: [mock] + + - name: "Resolve invalid cardinal falls back to center" + action: script + script: | + d = dev._d + d.clear_calls() + dev.text("X", at="INVALID") + result = len(d.calls) == 1 and d.calls[0][0] == "text" + expect_true: true + mode: [mock] + + - name: "circle outline draws pixels" + action: script + script: | + d = dev._d + d.clear_calls() + dev.circle(64, 64, 10) + pixel_calls = [c for c in d.calls if c[0] == "pixel"] + result = len(pixel_calls) > 0 + expect_true: true + mode: [mock] + + - name: "circle fill draws lines" + action: script + script: | + d = dev._d + d.clear_calls() + dev.circle(64, 64, 10, fill=True) + line_calls = [c for c in d.calls if c[0] == "line"] + result = len(line_calls) > 0 + expect_true: true + mode: [mock]