From 66dcd0cf15e646744a9dc94a1afa9f9b3559814e Mon Sep 17 00:00:00 2001 From: Kaan Ozen Date: Fri, 10 Apr 2026 14:39:27 +0200 Subject: [PATCH 1/4] feat(ism330dl): Add OLED spirit level example. --- lib/ism330dl/README.md | 1 + lib/ism330dl/examples/spirit_level.py | 114 ++++++++++++++++++++++++++ 2 files changed, 115 insertions(+) create mode 100644 lib/ism330dl/examples/spirit_level.py diff --git a/lib/ism330dl/README.md b/lib/ism330dl/README.md index c742b034..9c5d4bcf 100644 --- a/lib/ism330dl/README.md +++ b/lib/ism330dl/README.md @@ -285,5 +285,6 @@ The repository provides several example scripts: | `basic_read.py` | Simple sensor readout | | `static_orientation.py` | Detect device orientation using the accelerometer | | `motion_orientation.py` | Detect rotation using the gyroscope | +| `spirit_level.py` | Interactive digital bubble level using SSD1327 OLED| --- diff --git a/lib/ism330dl/examples/spirit_level.py b/lib/ism330dl/examples/spirit_level.py new file mode 100644 index 00000000..40e11d7b --- /dev/null +++ b/lib/ism330dl/examples/spirit_level.py @@ -0,0 +1,114 @@ +"""Spirit level example using ISM330DL accelerometer and SSD1327 OLED. + +Displays a digital bubble level. The bubble moves according to the board's tilt. +When the board is perfectly flat, the bubble centers and the background lights up. +""" + +from time import sleep_ms + +import ssd1327 +from ism330dl import ISM330DL +from machine import I2C, SPI, Pin + +# Layout & Physics Constants +SCREEN_CENTER_X = 64 +SCREEN_CENTER_Y = 64 +BUBBLE_RADIUS = 8 + +# Maximum pixel distance the bubble can travel from the center +MAX_OFFSET = 50 + +# Tilt thresholds (in g) to consider the board "level/flat" +LEVEL_THRESHOLD = 0.05 + +# Display Colors (0 to 15 greyscale) +COLOR_BG_TILTED = 0 +COLOR_BG_LEVEL = 4 +COLOR_FG = 15 + +# Loop delay +POLL_RATE_MS = 20 + +def fill_circle(fbuf, x0, y0, r, c): + """Helper to draw a filled circle since framebuf lacks it natively.""" + for y in range(-r, r + 1): + for x in range(-r, r + 1): + if x * x + y * y <= r * r: + fbuf.pixel(x0 + x, y0 + y, c) + +# Hardware Initialization +# I2C and ISM330DL +i2c = I2C(1) +imu = ISM330DL(i2c) + +# SPI and SSD1327 OLED +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) + +print("=======================") +print(" Spirit Level ") +print("=======================") +print("Tilt the board to move the bubble.") +print("Press Ctrl+C to exit.") + +try: + while True: + # Read acceleration in g-forces + ax, ay, _az = imu.acceleration_g() + + # Level Detection + # If both X and Y axes are close to 0g, the board is flat + is_level = abs(ax) < LEVEL_THRESHOLD and abs(ay) < LEVEL_THRESHOLD + + bg_color = COLOR_BG_LEVEL if is_level else COLOR_BG_TILTED + + # Map Acceleration to Pixel Offset + # We cap the acceleration at 1.0g to avoid the bubble leaving the screen + clamped_ax = max(-1.0, min(1.0, ax)) + clamped_ay = max(-1.0, min(1.0, ay)) + + + # Axis Mapping & Inversion: + # We swap X and Y to match the display's physical orientation. + # The negative sign on 'ay' inverts the axis so the indicator + # behaves like a physical air bubble (moving to the highest point). + offset_x = int(-clamped_ay * MAX_OFFSET) + offset_y = int(clamped_ax * MAX_OFFSET) + + bubble_x = SCREEN_CENTER_X + offset_x + bubble_y = SCREEN_CENTER_Y + offset_y + + bubble_x = max(BUBBLE_RADIUS, min(127 - BUBBLE_RADIUS, bubble_x)) + bubble_y = max(BUBBLE_RADIUS, min(127 - BUBBLE_RADIUS, bubble_y)) + + # Drawing Phase + display.fill(bg_color) + + # Draw the crosshair (Target reference) + display.framebuf.hline(SCREEN_CENTER_X - 20, SCREEN_CENTER_Y, 40, COLOR_FG) + display.framebuf.vline(SCREEN_CENTER_X, SCREEN_CENTER_Y - 20, 40, COLOR_FG) + + # Draw the center circle (Target zone) + display.framebuf.rect(SCREEN_CENTER_X - BUBBLE_RADIUS - 2, + SCREEN_CENTER_Y - BUBBLE_RADIUS - 2, + (BUBBLE_RADIUS * 2) + 4, + (BUBBLE_RADIUS * 2) + 4, + COLOR_FG) + + # Draw the actual bubble + fill_circle(display.framebuf, bubble_x, bubble_y, BUBBLE_RADIUS, COLOR_FG) + + display.show() + sleep_ms(POLL_RATE_MS) + +except KeyboardInterrupt: + print("\nSpirit level stopped.") +finally: + # Clean up and power off display on exit + display.fill(0) + display.show() + sleep_ms(100) + display.power_off() From 28b782dc511fc02c59f8e9736080543dd13f134d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=A9bastien=20NEDJAR?= Date: Thu, 16 Apr 2026 21:05:08 +0200 Subject: [PATCH 2/4] style(ism330dl): Remove extra blank line in spirit_level example. --- lib/ism330dl/examples/spirit_level.py | 1 - 1 file changed, 1 deletion(-) diff --git a/lib/ism330dl/examples/spirit_level.py b/lib/ism330dl/examples/spirit_level.py index 40e11d7b..a34d7ff6 100644 --- a/lib/ism330dl/examples/spirit_level.py +++ b/lib/ism330dl/examples/spirit_level.py @@ -70,7 +70,6 @@ def fill_circle(fbuf, x0, y0, r, c): clamped_ax = max(-1.0, min(1.0, ax)) clamped_ay = max(-1.0, min(1.0, ay)) - # Axis Mapping & Inversion: # We swap X and Y to match the display's physical orientation. # The negative sign on 'ay' inverts the axis so the indicator From 3ea0a77e88ac6b8f55e40290675cdb31df09d63b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=A9bastien=20NEDJAR?= Date: Thu, 16 Apr 2026 21:07:44 +0200 Subject: [PATCH 3/4] style(ism330dl): Extract SCREEN_SIZE constant and add missing blank line. --- lib/ism330dl/examples/spirit_level.py | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/lib/ism330dl/examples/spirit_level.py b/lib/ism330dl/examples/spirit_level.py index a34d7ff6..bf9eb441 100644 --- a/lib/ism330dl/examples/spirit_level.py +++ b/lib/ism330dl/examples/spirit_level.py @@ -11,8 +11,9 @@ from machine import I2C, SPI, Pin # Layout & Physics Constants -SCREEN_CENTER_X = 64 -SCREEN_CENTER_Y = 64 +SCREEN_SIZE = 128 +SCREEN_CENTER_X = SCREEN_SIZE // 2 +SCREEN_CENTER_Y = SCREEN_SIZE // 2 BUBBLE_RADIUS = 8 # Maximum pixel distance the bubble can travel from the center @@ -29,6 +30,7 @@ # Loop delay POLL_RATE_MS = 20 + def fill_circle(fbuf, x0, y0, r, c): """Helper to draw a filled circle since framebuf lacks it natively.""" for y in range(-r, r + 1): @@ -80,8 +82,8 @@ def fill_circle(fbuf, x0, y0, r, c): bubble_x = SCREEN_CENTER_X + offset_x bubble_y = SCREEN_CENTER_Y + offset_y - bubble_x = max(BUBBLE_RADIUS, min(127 - BUBBLE_RADIUS, bubble_x)) - bubble_y = max(BUBBLE_RADIUS, min(127 - BUBBLE_RADIUS, bubble_y)) + bubble_x = max(BUBBLE_RADIUS, min(SCREEN_SIZE - 1 - BUBBLE_RADIUS, bubble_x)) + bubble_y = max(BUBBLE_RADIUS, min(SCREEN_SIZE - 1 - BUBBLE_RADIUS, bubble_y)) # Drawing Phase display.fill(bg_color) From d6fdead555fa83794de025b9826b84e053b08993 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=A9bastien=20NEDJAR?= Date: Thu, 16 Apr 2026 21:16:44 +0200 Subject: [PATCH 4/4] fix(ism330dl): Add auto-zero calibration and IMU power-off to spirit level. Without calibration, the 0.05g level-detection threshold is too tight for most boards: accelerometer bias keeps the bubble off-center at rest and the "level" state may never trigger. Add a startup auto-zero phase that averages 20 samples on a flat surface and subtracts the measured bias from every subsequent reading. Also power off the IMU in the finally block so a Ctrl+C does not leave the sensor running at 104 Hz, draining the battery until the next reboot. --- lib/ism330dl/examples/spirit_level.py | 38 ++++++++++++++++++++++----- 1 file changed, 32 insertions(+), 6 deletions(-) diff --git a/lib/ism330dl/examples/spirit_level.py b/lib/ism330dl/examples/spirit_level.py index bf9eb441..00b7f675 100644 --- a/lib/ism330dl/examples/spirit_level.py +++ b/lib/ism330dl/examples/spirit_level.py @@ -2,6 +2,9 @@ Displays a digital bubble level. The bubble moves according to the board's tilt. When the board is perfectly flat, the bubble centers and the background lights up. + +On startup, the board must be placed on a flat surface for a brief auto-zero +calibration (averages a few samples to compensate accelerometer bias). """ from time import sleep_ms @@ -22,6 +25,10 @@ # Tilt thresholds (in g) to consider the board "level/flat" LEVEL_THRESHOLD = 0.05 +# Auto-zero calibration: number of samples averaged at startup +CAL_SAMPLES = 20 +CAL_DELAY_MS = 50 + # Display Colors (0 to 15 greyscale) COLOR_BG_TILTED = 0 COLOR_BG_LEVEL = 4 @@ -38,28 +45,46 @@ def fill_circle(fbuf, x0, y0, r, c): if x * x + y * y <= r * r: fbuf.pixel(x0 + x, y0 + y, c) -# Hardware Initialization -# I2C and ISM330DL + +# --- Hardware Initialization --- + i2c = I2C(1) imu = ISM330DL(i2c) -# SPI and SSD1327 OLED 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) +# --- Auto-zero calibration --- +# Average N acceleration samples to compensate the board's bias at rest. +# The board must be on a flat surface during this phase. + print("=======================") print(" Spirit Level ") print("=======================") +print("Calibrating... keep the board flat and still.") + +cal_ax, cal_ay = 0.0, 0.0 +for _ in range(CAL_SAMPLES): + ax, ay, _az = imu.acceleration_g() + cal_ax += ax + cal_ay += ay + sleep_ms(CAL_DELAY_MS) +cal_ax /= CAL_SAMPLES +cal_ay /= CAL_SAMPLES +print("Offset: ax={:.3f}g, ay={:.3f}g".format(cal_ax, cal_ay)) + print("Tilt the board to move the bubble.") print("Press Ctrl+C to exit.") try: while True: - # Read acceleration in g-forces - ax, ay, _az = imu.acceleration_g() + # Read acceleration in g-forces, subtract startup bias + raw_ax, raw_ay, _az = imu.acceleration_g() + ax = raw_ax - cal_ax + ay = raw_ay - cal_ay # Level Detection # If both X and Y axes are close to 0g, the board is flat @@ -108,8 +133,9 @@ def fill_circle(fbuf, x0, y0, r, c): except KeyboardInterrupt: print("\nSpirit level stopped.") finally: - # Clean up and power off display on exit + # Clean up: power off display and IMU to avoid battery drain display.fill(0) display.show() sleep_ms(100) display.power_off() + imu.power_off()