diff --git a/lib/steami_screen/examples/gauge_demo.py b/lib/steami_screen/examples/gauge_demo.py index 6ba9eb89..d98e133d 100644 --- a/lib/steami_screen/examples/gauge_demo.py +++ b/lib/steami_screen/examples/gauge_demo.py @@ -1,35 +1,88 @@ -""" -Displays VL53L1X time-of-flight distance with an arc gauge. +"""Gauge demo example using VL53L1X distance sensor and SSD1327 OLED. + +Displays time-of-flight distance as an arc gauge on the round screen. +The gauge color and arc width change dynamically based on the measured +distance, providing a clear visual proximity indicator. + +Color zones: + WHITE → object very close (< 150 mm) + YELLOW → object at medium range (< 350 mm) + LIGHT → object far away (>= 350 mm) + +Arc width: + Thicker arc when close, thinner when far. + +Reactivity: + The sensor is polled every 10 ms but the display is only redrawn + when the distance changes by more than REDRAW_THRESHOLD mm, + avoiding unnecessary SPI transfers and keeping the gauge fluid. """ from time import sleep_ms +import micropython import ssd1327 from machine import I2C, SPI, Pin -from steami_screen import BLACK, Screen, SSD1327Display +from steami_screen import LIGHT, WHITE, YELLOW, Screen, SSD1327Display from vl53l1x import VL53L1X -# --- Display setup --- +# === Display setup === spi = SPI(1) dc = Pin("DATA_COMMAND_DISPLAY") res = Pin("RST_DISPLAY") cs = Pin("CS_DISPLAY") - -raw_display = ssd1327.WS_OLED_128X128_SPI(spi, dc, res, cs) -display = SSD1327Display(raw_display) +display = SSD1327Display(ssd1327.WS_OLED_128X128_SPI(spi, dc, res, cs)) screen = Screen(display) -# --- Sensor setup --- +# === Sensor setup === i2c = I2C(1) sensor = VL53L1X(i2c) -# --- Main loop --- -while True: - dist = sensor.read() +# === Constants === +MAX_DIST = 500 +REDRAW_THRESHOLD = 10 # mm — minimum change to trigger a redraw + + +def dist_to_color(dist): + """Return gauge color based on measured distance.""" + if dist < 150: + return WHITE + elif dist < 350: + return YELLOW + else: + return LIGHT + +def dist_to_arc_width(dist): + """Return arc width based on distance — thicker when closer.""" + ratio = 1.0 - max(0.0, min(1.0, dist / MAX_DIST)) + return int(5 + ratio * 12) + + +@micropython.native +def redraw(dist): + """Redraw the full gauge screen.""" + color = dist_to_color(dist) + arc_w = dist_to_arc_width(dist) screen.clear() - screen.gauge(dist, min_val=0, max_val=500, color=BLACK) - screen.value(dist, label="Distance", unit="mm") + screen.gauge(dist, min_val=0, max_val=MAX_DIST, color=color, arc_width=arc_w) + screen.text(f"{dist} mm", at="CENTER") + screen.subtitle("Distance") screen.show() - sleep_ms(10) + +# === Main loop === +last_dist = -1 + +try: + while True: + dist = sensor.read() + if abs(dist - last_dist) >= REDRAW_THRESHOLD: + last_dist = dist + redraw(dist) + sleep_ms(10) +except KeyboardInterrupt: + pass +finally: + screen.clear() + screen.show() diff --git a/lib/steami_screen/steami_screen/device.py b/lib/steami_screen/steami_screen/device.py index 302e4f5d..51fc3d49 100644 --- a/lib/steami_screen/steami_screen/device.py +++ b/lib/steami_screen/steami_screen/device.py @@ -17,39 +17,40 @@ # --- Color constants (RGB tuples) --- # 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 +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) +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), + "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), + "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 --- + 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 + CHAR_W = 8 # framebuf built-in font width + CHAR_H = 8 # framebuf built-in font height 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) + self.width = width or getattr(display, "width", 128) + self.height = height or getattr(display, "height", 128) # --- Adaptive properties --- @@ -72,36 +73,31 @@ def _safe_margin(self, tw, from_edge): 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 + return r 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 + return max(min_margin + 2, from_edge) 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 + tw = text_len * self.CHAR_W * scale - # 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 + margin_ns = self._safe_margin(tw, ch * 2 + 4) + margin_ew = ch + 4 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), + "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"]) @@ -113,8 +109,9 @@ def title(self, text, color=GRAY): 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): + 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 @@ -122,7 +119,6 @@ def value(self, val, unit=None, at="CENTER", label=None, char_h = self.CHAR_H * scale tw = len(text) * char_w // 2 - # Compute vertical position: center the value+unit block if unit: gap = char_h // 3 unit_h = self.CHAR_H @@ -143,19 +139,16 @@ def value(self, val, unit=None, at="CENTER", label=None, 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 ux = x + tw // 2 - len(unit) * self.CHAR_W // 2 - if hasattr(self._d, 'draw_medium_text'): + 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) @@ -175,7 +168,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._d.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 @@ -190,55 +183,54 @@ def bar(self, val, max_val=100, y_offset=0, color=LIGHT): by = cy + 20 + y_offset fill_w = int(bar_w * min(val, max_val) / max_val) if max_val else 0 - # 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, color=LIGHT): + def gauge(self, val, min_val=0, max_val=100, color=LIGHT, arc_width=None): """Draw a circular arc gauge (270 deg, gap at bottom). - The arc is drawn close to the screen border. Call gauge() before + The arc is drawn close to the screen border. Call gauge() before title() so that text layers on top of the arc. + + Args: + val: Current value to display. + min_val: Minimum value (empty gauge). + max_val: Maximum value (full gauge). + color: Color of the filled arc. + arc_width: Optional custom arc thickness in pixels. + Defaults to max(5, radius // 9). """ cx, cy = self.center - arc_w = max(5, self.radius // 9) + arc_w = arc_width if arc_width is not None else 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) if max_val != min_val else 0 # Avoid division by zero; show empty gauge if min=max + ratio = (val - min_val) / (max_val - min_val) if max_val != min_val else 0 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 + 2) # +1 to fill gaps between segments + self._draw_arc(cx, cy, r, start_angle, int(sweep * ratio), color, arc_w + 2) - # Min/max labels at arc endpoints (slightly inward to stay visible) + # Min/max labels 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 = 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. - """ + """Draw a scrolling line graph with the current value above.""" cx, _cy = self.center margin = 15 gx = margin + 6 @@ -246,34 +238,28 @@ def graph(self, data, min_val=0, max_val=100, color=LIGHT): 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) + 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) + 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)]: + 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 @@ -282,15 +268,12 @@ def _fmt(v): self._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: @@ -310,8 +293,6 @@ def menu(self, items, selected=0, color=WHITE): """Draw a scrollable list menu.""" 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 @@ -328,18 +309,15 @@ def compass(self, heading, color=LIGHT): 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 @@ -351,27 +329,19 @@ def compass(self, heading, color=LIGHT): 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): @@ -379,10 +349,8 @@ def watch(self, hours, minutes, seconds=0, color=LIGHT): 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) @@ -398,7 +366,6 @@ def watch(self, hours, minutes, seconds=0, color=LIGHT): 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) @@ -407,7 +374,6 @@ def watch(self, hours, minutes, seconds=0, color=LIGHT): 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) @@ -418,7 +384,6 @@ def watch(self, hours, minutes, seconds=0, color=LIGHT): 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) @@ -429,26 +394,16 @@ def watch(self, hours, minutes, seconds=0, color=LIGHT): 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. - """ + """Draw a pixel-art face expression (8x8 bitmap scaled up).""" if isinstance(expression, str): bitmap = FACES.get(expression) if bitmap is None: @@ -458,11 +413,11 @@ def face(self, expression, compact=False, color=LIGHT): cx, cy = self.center if compact: - scale = self.width // 16 # 8 on 128px + scale = self.width // 16 ox = cx - 4 * scale oy = cy - 4 * scale - scale // 2 else: - scale = (self.width * 11) // 128 # 11 on 128px + scale = (self.width * 11) // 128 ox = cx - 4 * scale oy = cy - 4 * scale @@ -470,8 +425,9 @@ def face(self, expression, compact=False, color=LIGHT): byte = bitmap[row] for col in range(8): if byte & (0x80 >> col): - self._fill_rect(ox + col * scale, oy + row * scale, - scale, scale, color) + self._fill_rect( + ox + col * scale, oy + row * scale, scale, scale, color + ) # --- Level 2: Cardinal text & shapes --- @@ -524,14 +480,14 @@ 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'): + if hasattr(self._d, "fill_rect"): 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, c) def _rect(self, x, y, w, h, c): - if hasattr(self._d, 'rect'): + if hasattr(self._d, "rect"): self._d.rect(x, y, w, h, c) else: self._hline(x, y, w, c) @@ -546,17 +502,12 @@ def _draw_scaled_text(self, text, x, y, color, scale): Otherwise, the text is drawn multiple times with a 1px offset to produce a bold effect (not a true pixel-scale zoom). """ - if hasattr(self._d, 'draw_scaled_text'): + 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'): + 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): @@ -570,13 +521,12 @@ def _draw_scaled_text(self, text, x, y, color, scale): def _draw_arc(self, cx, cy, r, start_deg, sweep_deg, color, width=3): """Draw a thick arc.""" - if hasattr(self._d, 'draw_arc'): + if hasattr(self._d, "draw_arc"): self._d.draw_arc(cx, cy, r, start_deg, sweep_deg, color, width) return - # Number of steps based on arc length, with oversampling to avoid gaps arc_len = abs(math.radians(sweep_deg) * r) - steps = max(int(arc_len * 2), 1) # Oversample to avoid gaps + steps = max(int(arc_len * 1.5), 1) half_w = width // 2 prev_points = None @@ -604,8 +554,16 @@ 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)): + 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) @@ -628,7 +586,6 @@ def _fill_circle(self, cx, cy, r, 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