diff --git a/lib/ssd1327/ssd1327/device.py b/lib/ssd1327/ssd1327/device.py index adae168c..dd538150 100644 --- a/lib/ssd1327/ssd1327/device.py +++ b/lib/ssd1327/ssd1327/device.py @@ -159,6 +159,9 @@ def scroll(self, dx, dy): def text(self, string, x, y, col=15): self.framebuf.text(string, x, y, col) + def fill_rect(self, x, y, w, h, col): + self.framebuf.fill_rect(x, y, w, h, col) + def write_cmd(self): raise NotImplementedError diff --git a/lib/steami_screen/README.md b/lib/steami_screen/README.md index 3e1a16d7..da61d15b 100644 --- a/lib/steami_screen/README.md +++ b/lib/steami_screen/README.md @@ -21,14 +21,15 @@ Provides a device-agnostic abstraction layer on top of display drivers (SSD1327, ```python import ssd1327 from machine import SPI, Pin -from steami_screen import Screen +from steami_screen import Screen, SSD1327Display spi = SPI(1) dc = Pin("DATA_COMMAND_DISPLAY") res = Pin("RST_DISPLAY") cs = Pin("CS_DISPLAY") -display = ssd1327.WS_OLED_128X128_SPI(spi, dc, res, cs) +raw = ssd1327.WS_OLED_128X128_SPI(spi, dc, res, cs) +display = SSD1327Display(raw) screen = Screen(display) screen.clear() @@ -120,7 +121,7 @@ screen.bar(75, max_val=100) #### Gauge ```python -screen.gauge(60, min_val=0, max_val=100, unit="C") +screen.gauge(60, min_val=0, max_val=100) ``` Draws a 270-degree arc gauge near the screen border. diff --git a/lib/steami_screen/examples/comfort_bar_demo.py b/lib/steami_screen/examples/comfort_bar_demo.py new file mode 100644 index 00000000..284b870f --- /dev/null +++ b/lib/steami_screen/examples/comfort_bar_demo.py @@ -0,0 +1,38 @@ +""" +Shows temperature with a bar graph indicating how close it is to 40°C. +""" + +from time import sleep_ms + +import ssd1327 +from hts221 import HTS221 +from machine import I2C, SPI, Pin +from steami_screen import GREEN, Screen, SSD1327Display + +# --- Screen 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) +screen = Screen(display) + +# --- Sensor setup --- +i2c = I2C(1) +sensor = HTS221(i2c) + +# --- Main loop --- +while True: + temp = round(sensor.temperature(), 1) + + screen.clear() + screen.title("Temp") + + screen.value(temp, unit="C", label="TEMP") + screen.bar(temp, max_val=40, color=GREEN) + + screen.show() + + sleep_ms(100) diff --git a/lib/steami_screen/examples/compass_demo.py b/lib/steami_screen/examples/compass_demo.py new file mode 100644 index 00000000..bd83f76f --- /dev/null +++ b/lib/steami_screen/examples/compass_demo.py @@ -0,0 +1,42 @@ +""" +Displays a compass with a rotating needle based on the LIS2MDL magnetometer. +""" + +from time import sleep_ms + +import ssd1327 +from lis2mdl import LIS2MDL +from machine import I2C, 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") + +raw_display = ssd1327.WS_OLED_128X128_SPI(spi, dc, res, cs) +display = SSD1327Display(raw_display) +screen = Screen(display) + +# --- Sensor setup --- +i2c = I2C(1) +sensor = LIS2MDL(i2c) + +print("Calibrate the magnetometer by moving it flat.") +sensor.calibrate_minmax_2d() +print("Calibration complete.") + +heading = 0 + +# --- Main loop --- +while True: + angle = sensor.heading_flat_only() + + screen.clear() + screen.compass(angle) + screen.show() + + print("Cap:", angle, "°") + + sleep_ms(50) diff --git a/lib/steami_screen/examples/ddap_menu.py b/lib/steami_screen/examples/ddap_menu.py new file mode 100644 index 00000000..c805a524 --- /dev/null +++ b/lib/steami_screen/examples/ddap_menu.py @@ -0,0 +1,58 @@ +""" +Displays a scrollable menu navigated with the D-pad buttons. +""" + +from time import sleep_ms + +import ssd1327 +from machine import I2C, SPI, Pin +from mcp23009e import MCP23009E +from mcp23009e.const import ( + MCP23009_BTN_DOWN, + MCP23009_BTN_UP, + MCP23009_DIR_INPUT, + MCP23009_I2C_ADDR, + MCP23009_LOGIC_LOW, + MCP23009_PULLUP, +) +from steami_screen import Screen, SSD1327Display + +# --- Screen 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) +screen = Screen(display) + +# --- D-pad setup --- +i2c = I2C(1) + +reset = Pin("RST_EXPANDER", Pin.OUT) +mcp = MCP23009E(i2c, address=MCP23009_I2C_ADDR, reset_pin=reset) + +mcp.setup(MCP23009_BTN_UP, MCP23009_DIR_INPUT, pullup=MCP23009_PULLUP) +mcp.setup(MCP23009_BTN_DOWN, MCP23009_DIR_INPUT, pullup=MCP23009_PULLUP) + +# --- Menu items --- +items = ["Temperature", "Humidity", "Distance", "Light", "Battery", "Proximity"] +selected = 0 + +# --- Main loop --- +while True: + if mcp.get_level(MCP23009_BTN_UP) == MCP23009_LOGIC_LOW: + selected = (selected - 1) % len(items) + sleep_ms(200) + + if mcp.get_level(MCP23009_BTN_DOWN) == MCP23009_LOGIC_LOW: + selected = (selected + 1) % len(items) + sleep_ms(200) + + screen.clear() + screen.title("Menu") + screen.menu(items, selected=selected) + screen.show() + + sleep_ms(50) diff --git a/lib/steami_screen/examples/face_gallery.py b/lib/steami_screen/examples/face_gallery.py new file mode 100644 index 00000000..103b3624 --- /dev/null +++ b/lib/steami_screen/examples/face_gallery.py @@ -0,0 +1,39 @@ +""" +Cycle through all 6 built-in face expressions. +""" + +from time import sleep_ms + +import ssd1327 +from machine import SPI, Pin +from steami_screen import Screen, SSD1327Display + +# --- 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) +screen = Screen(display) + +faces = [ + ("happy", "HAPPY"), + ("sad", "SAD"), + ("surprised", "SURPRISED"), + ("sleeping", "SLEEPING"), + ("angry", "ANGRY"), + ("love", "LOVE"), +] + +compact = True + +while True: + compact = not compact + for expression, label in faces: + screen.clear() + screen.face(expression, compact=compact) + screen.show() + print("Showing face:", label, "Compact:", compact) + sleep_ms(1000) diff --git a/lib/steami_screen/examples/gauge_demo.py b/lib/steami_screen/examples/gauge_demo.py new file mode 100644 index 00000000..6ba9eb89 --- /dev/null +++ b/lib/steami_screen/examples/gauge_demo.py @@ -0,0 +1,35 @@ +""" +Displays VL53L1X time-of-flight distance with an arc gauge. +""" + +from time import sleep_ms + +import ssd1327 +from machine import I2C, SPI, Pin +from steami_screen import BLACK, Screen, SSD1327Display +from vl53l1x import VL53L1X + +# --- 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) +screen = Screen(display) + +# --- Sensor setup --- +i2c = I2C(1) +sensor = VL53L1X(i2c) + +# --- Main loop --- +while True: + dist = sensor.read() + + screen.clear() + screen.gauge(dist, min_val=0, max_val=500, color=BLACK) + screen.value(dist, label="Distance", unit="mm") + screen.show() + + sleep_ms(10) diff --git a/lib/steami_screen/examples/graph_demo.py b/lib/steami_screen/examples/graph_demo.py new file mode 100644 index 00000000..1c28f25f --- /dev/null +++ b/lib/steami_screen/examples/graph_demo.py @@ -0,0 +1,43 @@ +""" +Displays APDS9960 ambient light with a scrolling line graph. +""" + +from time import sleep_ms + +import ssd1327 +from apds9960 import uAPDS9960 as APDS9960 +from machine import I2C, SPI, Pin +from steami_screen import Screen, SSD1327Display + +# --- 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) +screen = Screen(display) + +# --- Sensor setup --- +i2c = I2C(1) +apds = APDS9960(i2c) + +# --- Data buffer (scrolling window) --- +MAX_POINTS = 20 +data = [] + +# --- Main loop --- +while True: + lux = apds.ambient_light() + data.append(lux) + if len(data) > MAX_POINTS: + data.pop(0) + + screen.clear() + screen.title("Light (lux)") + screen.graph(data, min_val=0, max_val=1000) + screen.subtitle("APDS9960", "20s window") + screen.show() + + sleep_ms(1000) diff --git a/lib/steami_screen/examples/hello_world.py b/lib/steami_screen/examples/hello_world.py new file mode 100644 index 00000000..4c20aeb3 --- /dev/null +++ b/lib/steami_screen/examples/hello_world.py @@ -0,0 +1,38 @@ +""" +hello_world.py +Basic steami_screen example: +- clear screen +- title +- value +- subtitle +- show +""" + +from time import sleep_ms + +import ssd1327 +from machine import SPI, Pin +from steami_screen import Screen, SSD1327Display + +# --- 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) +screen = Screen(display) + +# --- Demo loop --- +counter = 0 + +while True: + screen.clear() + screen.title("HELLO") + screen.subtitle("screen", "Hello world") + screen.value(counter, label="Demo") + screen.show() + + counter += 1 + sleep_ms(1000) diff --git a/lib/steami_screen/steami_screen/device.py b/lib/steami_screen/steami_screen/device.py index 2354d9d5..302e4f5d 100644 --- a/lib/steami_screen/steami_screen/device.py +++ b/lib/steami_screen/steami_screen/device.py @@ -120,7 +120,7 @@ def value(self, val, unit=None, at="CENTER", label=None, cx, cy = self.center char_w = self.CHAR_W * scale char_h = self.CHAR_H * scale - tw = len(text) * char_w + tw = len(text) * char_w // 2 # Compute vertical position: center the value+unit block if unit: @@ -136,10 +136,10 @@ def value(self, val, unit=None, at="CENTER", label=None, y = vy + y_offset elif at == "W": x = self.width // 4 - tw // 2 - y = vy + y = vy + y_offset elif at == "E": x = 3 * self.width // 4 - tw // 2 - y = vy + y = vy + y_offset else: x, y = self._resolve(at, len(text), scale) @@ -153,7 +153,7 @@ def value(self, val, unit=None, at="CENTER", label=None, # Optional unit below (medium font if backend supports it) if unit: - unit_y = y + char_h + char_h // 3 + unit_y = y + char_h 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) @@ -196,7 +196,8 @@ def bar(self, val, max_val=100, y_offset=0, color=LIGHT): 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): + + def gauge(self, val, min_val=0, max_val=100, color=LIGHT): """Draw a circular arc gauge (270 deg, gap at bottom). The arc is drawn close to the screen border. Call gauge() before @@ -215,28 +216,7 @@ def gauge(self, val, min_val=0, max_val=100, unit=None, color=LIGHT): # 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) + color, arc_w + 2) # +1 to fill gaps between segments # Min/max labels at arc endpoints (slightly inward to stay visible) min_t = str(int(min_val)) @@ -589,20 +569,37 @@ def _draw_scaled_text(self, text, x, y, color, scale): 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.""" + """Draw a thick arc.""" 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) + + # 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 half_w = width // 2 + + prev_points = None + for i in range(steps + 1): angle = math.radians(start_deg + i * sweep_deg / steps) + + curr_points = [] 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: + rr = r + dr + x = round(cx + rr * math.cos(angle)) + y = round(cy + rr * math.sin(angle)) + curr_points.append((x, y)) + + if prev_points is None and 0 <= x < self.width and 0 <= y < self.height: self._d.pixel(x, y, color) + if prev_points is not None: + for (x0, y0), (x1, y1) in zip(prev_points, curr_points): + self._line(x0, y0, x1, y1, color) + + prev_points = curr_points + def _draw_circle(self, cx, cy, r, color): """Bresenham circle.""" x, y, d = r, 0, 1 - r