Once you've finished setting up your Qualia S3 with CircuitPython, you can access the code and necessary libraries by downloading the Project Bundle.
To do this, click on the Download Project Bundle button in the window below. It will download to your computer as a zipped folder.
# SPDX-FileCopyrightText: 2024 Liz Clark for Adafruit Industries # SPDX-License-Identifier: MIT # Written by Liz Clark (Adafruit Industries) # with OpenAI ChatGPT v4 Jan 10, 2024 build # https://help.openai.com/en/articles/6825453-chatgpt-release-notes # https://chat.openai.com/share/19de8f24-3191-43b8-a9c9-95f1b8000e80 import time from math import atan2, degrees, cos, sin, radians import adafruit_lis3mdl import vectorio import displayio from adafruit_display_text import bitmap_label from adafruit_bitmap_font import bitmap_font from adafruit_qualia.graphics import Graphics, Displays from adafruit_lsm6ds.lsm6dsox import LSM6DSOX from gamblor21_ahrs import mahony import bitmaptools from jpegio import JpegDecoder # change these values to your calibration values MAG_MIN = [-11.5902, -47.1353, -28.7635] MAG_MAX = [79.7866, 48.0854, 63.461] GYRO_CAL = [-7.3934, -0.000100605, 2.7703] # use filter for more accurate, but slightly slower readings # otherwise just reads from magnetometer ahrs = True center_x, center_y = 240, 240 graphics = Graphics(Displays.ROUND21, default_bg=None, auto_refresh=False) i2c = graphics.i2c_bus accel_gyro = LSM6DSOX(i2c) magnetometer = adafruit_lis3mdl.LIS3MDL(i2c) # Create the AHRS filter ahrs_filter = mahony.Mahony(50, 5, 100) group = displayio.Group() # palette for vectorio graphics pointer_pal = displayio.Palette(5) pointer_pal[0] = 0xFFFF00 pointer_pal[1] = 0x000000 pointer_pal[2] = 0xFFFFFF pointer_pal[3] = 0xFF0000 pointer_pal[4] = 0x0000FF pointer_pal.make_transparent(0) # compass image is a jpeg decoder = JpegDecoder() width, height = decoder.open("/compass.jpg") bitmap_compass = displayio.Bitmap(width, height, 20) palette_compass = displayio.ColorConverter(input_colorspace = displayio.Colorspace.RGB565_SWAPPED) decoder.decode(bitmap_compass) # blank bitmap for rotozoom compass_blank = displayio.Bitmap(width, height, 1) # carrier bitmap for compass for rotozoom compass_scribble = displayio.Bitmap(width, height, 20) tile_grid = displayio.TileGrid(compass_scribble, pixel_shader=palette_compass) # only tilegrid is added to group group.append(tile_grid) radius = center_x angle = 225 rad_angle = radians(angle) # place small circle to denote heading direction from 9-DoF relative to display circle_radius = 5 header_angle = radians(135) edge_x = center_x + radius * cos(header_angle) edge_y = center_y + radius * sin(header_angle) adjusted_x = edge_x - circle_radius * cos(header_angle) adjusted_y = edge_y - circle_radius * sin(header_angle) header = vectorio.Circle(pixel_shader=pointer_pal, color_index = 2, radius=circle_radius, x=int(adjusted_x), y=int(adjusted_y)) center = vectorio.Circle(pixel_shader=pointer_pal, color_index = 2, radius=50, x=240, y=240) font_file = "/Roboto-Regular-47.pcf" font = bitmap_font.load_font(font_file) direction_text = bitmap_label.Label(font, text="000", color=None) direction_text.x = center.x - 40 direction_text.y = center.y direction_bitmap = direction_text.bitmap direction_blank = displayio.Bitmap(direction_text.bounding_box[2], direction_text.bounding_box[2], 1) direction_scribble = displayio.Bitmap(direction_text.bounding_box[2], direction_text.bounding_box[2], len(pointer_pal)) direction_grid = displayio.TileGrid(direction_scribble, pixel_shader=pointer_pal, x=200, y=200) def create_line_of_squares(l, n, color): squares = [] square_size = l // n for i in range(n): x = center_x + i * square_size y = center_y - square_size // 2 square_points = [(0, 0), (square_size, 0), (square_size, square_size), (0, square_size)] square = vectorio.Polygon(pixel_shader=pointer_pal, color_index=color, points=square_points, x=x, y=y) group.append(square) squares.append(square) return squares def update_line_of_squares(squares, h, l): r = radians(-h) n = len(squares) square_size = l // n x = center_x - (square_size - 2) y = center_y - (square_size - 2) for i, square in enumerate(squares): offset_x = x + i * square_size - x offset_y = -square_size // 2 rotated_x = offset_x * cos(r) - offset_y * sin(r) rotated_y = offset_x * sin(r) + offset_y * cos(r) square.x = int(x + rotated_x) square.y = int(y + rotated_y) length = 200 # Length of the lines num_squares = 20 # Number of squares for each line vertical_squares = create_line_of_squares(length, num_squares, 3) update_line_of_squares(vertical_squares, angle, length) def map_range(x, in_min, in_max, out_min, out_max): mapped = (x - in_min) * (out_max - out_min) / (in_max - in_min) + out_min if out_min <= out_max: return max(min(mapped, out_max), out_min) return min(max(mapped, out_max), out_min) group.append(center) group.append(header) group.append(direction_text) group.append(direction_grid) graphics.display.root_group = group last_heading = angle heading = angle last_update = time.monotonic() # last time we printed the yaw/pitch/roll values timestamp = time.monotonic_ns() # used to tune the frequency to approx 100 Hz spin_rose = True graphics.display.refresh() while True: if graphics.touch.touched: spin_rose = not spin_rose # reset last_heading to trigger an update last_heading = heading + 5 if spin_rose: direction_text.color = None update_line_of_squares(vertical_squares, angle, length) else: bitmaptools.rotozoom(compass_scribble, bitmap_compass, angle = radians(0)) direction_text.color = pointer_pal[1] graphics.display.refresh() # touch debounce delay time.sleep(0.2) if (time.monotonic_ns() - timestamp) > 6500000: mx, my, mz = magnetometer.magnetic cal_x = map_range(mx, MAG_MIN[0], MAG_MAX[0], -1, 1) cal_y = map_range(my, MAG_MIN[1], MAG_MAX[1], -1, 1) cal_z = map_range(mz, MAG_MIN[2], MAG_MAX[2], -1, 1) if ahrs: ax, ay, az, gx, gy, gz = accel_gyro.acceleration + accel_gyro.gyro gx += GYRO_CAL[0] gy += GYRO_CAL[1] gz += GYRO_CAL[2] ahrs_filter.update(gx, gy, -gz, ax, ay, az, cal_x, -cal_y, cal_z) yaw_degree = ahrs_filter.yaw heading = degrees(yaw_degree) else: heading = degrees(atan2(cal_y, cal_x)) timestamp = time.monotonic_ns() if time.monotonic() > last_update + 0.2: if heading < 0: heading += 360 if abs(last_heading - heading) >= 2: direction_text.text = str(int(heading)) if spin_rose: direction_bitmap = direction_text.bitmap bitmaptools.rotozoom(compass_scribble, bitmap_compass, angle = radians(-heading+angle)) bitmaptools.rotozoom(direction_scribble, direction_bitmap, angle = rad_angle) graphics.display.refresh() bitmaptools.rotozoom(direction_scribble, direction_blank, angle = rad_angle) bitmaptools.rotozoom(compass_scribble, compass_blank, angle = radians(-heading+angle)) else: update_line_of_squares(vertical_squares, -heading + 90, length) graphics.display.refresh() last_heading = heading last_update = time.monotonic()
Upload the Code and Libraries to the Qualia S3
After downloading the Project Bundle, plug your Qualia S3 into the computer's USB port with a known good USB data+power cable. You should see a new flash drive appear in the computer's File Explorer or Finder (depending on your operating system) called CIRCUITPY. Unzip the folder and copy the following items to the Qualia S3's CIRCUITPY drive:
- lib folder
- code.py
- compass.jpg
- Roboto-Regular-47.pcf
Your Qualia S3 CIRCUITPY drive should look like this after copying the lib folder, font file, JPEG file and the code.py file.
Install the AHRS Library from the Community Bundle
This project uses one additional library from the Community Bundle: the Gamblor21_CircuitPython_AHRS library. You'll need to download the Community Bundle and copy the /gamblor21_ahrs library folder to your /lib folder or use circup to install with:
circup install gamblor21-circuitpython-ahrs
How the CircuitPython Code Works
At the top of the code, you can insert your magnetometer and gyroscope calibration values after running the calibrate.py script. Details for this are available on the Calibrating the Magnetometer and Gyroscope guide page.
Additionally, you can select whether you want to use the Mahony AHRS filter for calculating your compass heading by changing ahrs
to True
or False
.
# change these values to your calibration values MAG_MIN = [-11.5902, -47.1353, -28.7635] MAG_MAX = [79.7866, 48.0854, 63.461] GYRO_CAL = [-7.3934, -0.000100605, 2.7703] # use filter for more accurate, but slightly slower readings # otherwise just reads from magnetometer ahrs = True center_x, center_y = 240, 240
I2C
The 2.1" round display is instantiated with the Qualia library. auto_refresh
is set to False
to manually refresh the screen in the loop when rotating graphics with rotozoom
. The I2C bus is instantiated through the library with graphics.i2c_bus
. This allows for the LSM6SDOX and LIS3MDL to be instantiated over I2C and plug into the STEMMA QT port on the Qualia.
graphics = Graphics(Displays.ROUND21, default_bg=None, auto_refresh=False) i2c = graphics.i2c_bus accel_gyro = LSM6DSOX(i2c) magnetometer = adafruit_lis3mdl.LIS3MDL(i2c)
Compass Rose
A Palette
is created for the vectorio
graphics used alongside the compass rose background image. The image is brought in as a JPEG image, which is a smaller file size than a bitmap. The compass rose is rotated using rotozoom
, which requires some additional setup. Two blank bitmaps are created that are the same size as the compass image. compass_blank
is made transparent by using the transparent Palette
index. compass_scribble
is passed to a TileGrid
with the same pixel_shader
as the compass image. Only the tile_grid
is added to the displayio
Group
.
group = displayio.Group() # palette for vectorio graphics pointer_pal = displayio.Palette(5) pointer_pal[0] = 0xFFFF00 pointer_pal[1] = 0x000000 pointer_pal[2] = 0xFFFFFF pointer_pal[3] = 0xFF0000 pointer_pal[4] = 0x0000FF pointer_pal.make_transparent(0) # compass image is a jpeg decoder = JpegDecoder() width, height = decoder.open("/compass.jpg") bitmap_compass = displayio.Bitmap(width, height, 20) palette_compass = displayio.ColorConverter(input_colorspace = displayio.Colorspace.RGB565_SWAPPED) decoder.decode(bitmap_compass) # blank bitmap for rotozoom compass_blank = displayio.Bitmap(width, height, 1) # carrier bitmap for compass for rotozoom compass_scribble = displayio.Bitmap(width, height, 20) tile_grid = displayio.TileGrid(compass_scribble, pixel_shader=palette_compass) # only tilegrid is added to group group.append(tile_grid)
Circles
Next, two circles are created. The header
circle is placed on the edge of the display relative to the heading from the 9-DoF sensor to let you know where the sensor is "pointing" when generating header information. This value is stored in angle
and is used throughout the code.
The center
circle is placed in the center of the display and acts as a background for displaying the header degrees text.
radius = center_x angle = 225 rad_angle = radians(angle) # place small circle to denote heading direction from 9-DoF relative to display circle_radius = 5 header_angle = radians(135) edge_x = center_x + radius * cos(header_angle) edge_y = center_y + radius * sin(header_angle) adjusted_x = edge_x - circle_radius * cos(header_angle) adjusted_y = edge_y - circle_radius * sin(header_angle) header = vectorio.Circle(pixel_shader=pointer_pal, color_index = 2, radius=circle_radius, x=int(adjusted_x), y=int(adjusted_y)) center = vectorio.Circle(pixel_shader=pointer_pal, color_index = 2, radius=50, x=240, y=240)
Text
The direction_text
is updated throughout the loop to show the compass degrees. Much like the compass rose image, rotozoom
is used to rotate the direction_text
. The width value of the text bounding_box
is used for the dimensions for the direction_blank
and direction_scribble
bitmaps.
font_file = "/Roboto-Regular-47.pcf" font = bitmap_font.load_font(font_file) direction_text = bitmap_label.Label(font, text="000", color=None) direction_text.x = center.x - 40 direction_text.y = center.y direction_bitmap = direction_text.bitmap direction_blank = displayio.Bitmap(direction_text.bounding_box[2], direction_text.bounding_box[2], 1) direction_scribble = displayio.Bitmap(direction_text.bounding_box[2], direction_text.bounding_box[2], len(pointer_pal)) direction_grid = displayio.TileGrid(direction_scribble, pixel_shader=pointer_pal, x=200, y=200)
Pointer
A "line" is used to denote heading direction in both compass views. This line originates from the center of the display and rotates. To achieve this, a series of squares are created as vectorio Polygon
s. The create_line_of_squares
function initially creates the squares and update_line_of_squares
is used to move the line by taking in the heading information. Initially, the line is drawn to point as the sensor's heading on the display.
def create_line_of_squares(l, n, color): squares = [] square_size = l // n for i in range(n): x = center_x + i * square_size y = center_y - square_size // 2 square_points = [(0, 0), (square_size, 0), (square_size, square_size), (0, square_size)] square = vectorio.Polygon(pixel_shader=pointer_pal, color_index=color, points=square_points, x=x, y=y) group.append(square) squares.append(square) return squares def update_line_of_squares(squares, h, l): r = radians(-h) n = len(squares) square_size = l // n x = center_x - (square_size - 2) y = center_y - (square_size - 2) for i, square in enumerate(squares): offset_x = x + i * square_size - x offset_y = -square_size // 2 rotated_x = offset_x * cos(r) - offset_y * sin(r) rotated_y = offset_x * sin(r) + offset_y * cos(r) square.x = int(x + rotated_x) square.y = int(y + rotated_y) length = 200 # Length of the lines num_squares = 20 # Number of squares for each line vertical_squares = create_line_of_squares(length, num_squares, 3) update_line_of_squares(vertical_squares, angle, length)
The Loop
In the loop, touch is monitored as a toggle switch for the different compass displays. If spin_rose
is True
, then the direction line is pointed to the sensor's angle and the compass rose rotates relative to this angle. If spin_rose
is False
, then the compass rose remains static and the direction line rotates relative to the compass rose to show heading direction.
When spin_rose
is True
, the direction_text
color is set to None
so that the rotozoom
bitmap of the text is visible. When spin_rose
is False
, the direction_text
color is set to black, and as a result visible, because the text is not rotated in that mode.
if graphics.touch.touched: spin_rose = not spin_rose # reset last_heading to trigger an update last_heading = heading + 5 if spin_rose: direction_text.color = None update_line_of_squares(vertical_squares, angle, length) else: bitmaptools.rotozoom(compass_scribble, bitmap_compass, angle = radians(0)) direction_text.color = pointer_pal[1] graphics.display.refresh() # touch debounce delay time.sleep(0.2)
Initially the magnetometer is read to calculate compass heading. If you have kept ahrs
True
, then the accelerometer and gyroscope are also read and passed to the ahrs_filter
to calculate yaw
for the compass heading.
If ahrs
is False
, then only the magnetometer data is used to calculate heading. This is less accurate than using the AHRS filter, but can be useful if you want to use a sensor that is not a 9-DoF sensor, like the LSM303AGR. It is also a little bit faster at generating a reading.
if (time.monotonic_ns() - timestamp) > 6500000: mx, my, mz = magnetometer.magnetic cal_x = map_range(mx, MAG_MIN[0], MAG_MAX[0], -1, 1) cal_y = map_range(my, MAG_MIN[1], MAG_MAX[1], -1, 1) cal_z = map_range(mz, MAG_MIN[2], MAG_MAX[2], -1, 1) if ahrs: ax, ay, az, gx, gy, gz = accel_gyro.acceleration + accel_gyro.gyro gx += GYRO_CAL[0] gy += GYRO_CAL[1] gz += GYRO_CAL[2] ahrs_filter.update(gx, gy, -gz, ax, ay, az, cal_x, -cal_y, cal_z) yaw_degree = ahrs_filter.yaw heading = degrees(yaw_degree) else: heading = degrees(atan2(cal_y, cal_x)) timestamp = time.monotonic_ns()
The display is updated when the heading changes. The direction_text
displays the heading degrees as an integer. If spin_rose
is True
, then rotozoom
is used to rotate the compass rose image so that the cardinal directions in the image are pointing properly. This is done by adding the angle
to the heading calculation so that the top of the image, which is north, is always pointing towards north. The text is rotated to be oriented correctly for the sensor heading direction on the display.
If spin_rose
is False
, then the line of squares is rotated to point to the heading direction on the compass rose, which remains static.
if time.monotonic() > last_update + 0.2: if heading < 0: heading += 360 if abs(last_heading - heading) >= 2: direction_text.text = str(int(heading)) if spin_rose: direction_bitmap = direction_text.bitmap bitmaptools.rotozoom(compass_scribble, bitmap_compass, angle = radians(-heading+angle)) bitmaptools.rotozoom(direction_scribble, direction_bitmap, angle = rad_angle) graphics.display.refresh() bitmaptools.rotozoom(direction_scribble, direction_blank, angle = rad_angle) bitmaptools.rotozoom(compass_scribble, compass_blank, angle = radians(-heading+angle)) else: update_line_of_squares(vertical_squares, -heading + 90, length) graphics.display.refresh() last_heading = heading last_update = time.monotonic()
Text editor powered by tinymce.