Ready to unleash the full potential of your Unihiker K10? With the standard MicroPython screen library, you get a solid foundation—but what if you want charts, more primitives (as dots, lines, circles and rects), and a workflow reminiscent of PyGame? That’s where ScreenExtension.py steps in!
This tutorial shows you how to supercharge the Unihiker K10 screen with a custom MicroPython extension. It will reduce repetitive code, let you draw shapes, text, and objects with ease, and help you craft stunning UIs with minimal effort.
Key Benefits:
- Predefined Colors: Skip defining RGB values over and over.
- Simplified Drawing API: Inspired by PyGame.
- Efficient Rendering: Combine multiple draw calls into clean UI updates.
- Expandable: Start with the provided features and extend further as you like!
Here is an overview of what is happening under the hood in ScreenExtension.py:
- Necessary MicroPython modules are imported
- Various colors are defined as constants (these can be imported if required)
- 4 classes (Validator, Draw, Add and Screen) are defined which provide specific methods
import micropython
from unihiker_k10 import screen
from math import radians, cos, sin, atan2
BLACK: int = micropython.const(0x000000)
WHITE: int = micropython.const(0xFFFFFF)
RED: int = micropython.const(0xFF0000)
GREEN: int = micropython.const(0x00FF00)
BLUE: int = micropython.const(0x0000FF)
YELLOW: int = micropython.const(0xFFFF00)
CYAN: int = micropython.const(0x00FFFF)
MAGENTA: int = micropython.const(0xFF00FF)
GRAY = micropython.const(0x808080)
ORANGE = micropython.const(0xFFA500)
BROWN = micropython.const(0x8B4513)
PINK = micropython.const(0xFFC0CB)
class Validator:
"""
Provides static methods for validating various input data types.
"""
@staticmethod
def validate_color(color) -> None:
if not isinstance(color, int):
raise TypeError(f"color must be a int eq. 0xFF0000, not:{type(color).__name__}")
@staticmethod
def validate_pos(pos, name="pos") -> None:
if not (isinstance(pos, tuple) and len(pos) == 2 and all(isinstance(c, int) for c in pos)):
raise ValueError(f"{name} must be a tuple with 2 int, e.q. (x, y)")
@staticmethod
def validate_coords(coords, name="coords") -> None:
if not (isinstance(coords, tuple) and len(coords) == 4 and all(isinstance(c, int) for c in coords)):
raise ValueError(f"{name} must be a tuple with 4 int, e.q. (x, y, w, h)")
@staticmethod
def validate_int(val, name="width") -> None:
if not isinstance(val, int) or val < 0:
raise ValueError(f"{name} must be an int >= 0")
@staticmethod
def validate_pointlist(pointlist, name="pointlist"):
if not (isinstance(pointlist, tuple) and len(pointlist) >= 3):
raise ValueError(f"{name} must be a tuple with at least 3 (x,y) points")
for idx, point in enumerate(pointlist):
if not (isinstance(point, tuple) and len(point) == 2 and all(isinstance(c, int) for c in point)):
raise ValueError(f"{name}[{idx}] must be a tuple of two int (x,y)")
@staticmethod
def validate_string(text) -> None:
if not isinstance(text, str):
raise ValueError("text must be a string")
class Draw:
"""
Provides various static methods for drawing graphical elements such as points,
lines, rectangles, circles, polygons, arcs, gradient rectangles, and text.
"""
@staticmethod
def point(color: int, pos: tuple) -> None:
Validator.validate_color(color)
Validator.validate_pos(pos)
x, y = pos
screen.draw_point(x=x, y=y, color=color)
@staticmethod
def line(color: int, start_pos: tuple, end_pos: tuple, width: int = 1) -> None:
Validator.validate_color(color)
Validator.validate_pos(start_pos, "start_pos")
Validator.validate_pos(end_pos, "end_pos")
Validator.validate_int(width, "width")
screen.set_width(width)
x0, y0 = start_pos
x1, y1 = end_pos
screen.draw_line(x0=x0, y0=y0, x1=x1, y1=y1, color=color)
if width > 1:
screen.set_width(1)
@staticmethod
def rect(color: int, coords: tuple, width: int = 0, outline: int = None):
Validator.validate_color(color)
Validator.validate_coords(coords, "coords")
if outline is not None and width > 0:
Validator.validate_int(width, "width")
Validator.validate_color(outline)
screen.set_width(width)
b_color = outline
else:
b_color = color
x, y, w, h = coords
screen.draw_rect(x=x, y=y, w=w, h=h, fcolor=color, bcolor=b_color)
if width > 1:
screen.set_width(1)
@staticmethod
def circle(color: int, center: tuple, radius: int, width: int = 0, outline: int = None) -> None:
Validator.validate_color(color)
Validator.validate_pos(center, "center")
Validator.validate_int(radius, "radius")
if outline is not None and width > 0:
Validator.validate_int(width, "width")
Validator.validate_color(outline)
screen.set_width(width)
b_color = outline
else:
b_color = color
x, y = center
screen.draw_circle(x=x ,y=y ,r=radius, fcolor=color, bcolor=b_color)
if width > 1:
screen.set_width(1)
@staticmethod
@micropython.native
def polygon(color: int, points: tuple, width: int = 0, outline: int = None) -> None:
Validator.validate_color(color)
Validator.validate_pointlist(points)
points_sorted = [(x, y) for x, y in points]
min_y = min(y for (x, y) in points_sorted)
max_y = max(y for (x, y) in points_sorted)
edges = []
n = len(points_sorted)
for i in range(n):
x0, y0 = points_sorted[i]
x1, y1 = points_sorted[(i + 1) % n]
if y0 == y1:
continue
if y0 > y1:
x0, y0, x1, y1 = x1, y1, x0, y0
edges.append({'x0': x0, 'y0': y0, 'x1': x1, 'y1': y1, 'inv_slope': (x1 - x0) / (y1 - y0)})
for y in range(min_y, max_y + 1):
intersections = []
for edge in edges:
if edge['y0'] <= y < edge['y1']:
x = edge['x0'] + (y - edge['y0']) * edge['inv_slope']
intersections.append(int(x))
intersections.sort()
for i in range(0, len(intersections), 2):
if i + 1 < len(intersections):
x_start, x_end = intersections[i], intersections[i + 1]
screen.draw_line(x0=x_start, y0=y, x1=x_end, y1=y, color=color)
if outline is not None and width > 0:
Validator.validate_int(width, "width")
Validator.validate_color(outline)
screen.set_width(width)
for i in range(len(points)):
x0, y0 = points[i]
x1, y1 = points[(i + 1) % len(points)]
screen.draw_line(x0=x0, y0=y0, x1=x1, y1=y1, color=outline)
if width > 1:
screen.set_width(1)
@staticmethod
@micropython.native
def arc(color: int, center: tuple, radius: int, start_angle: int, end_angle: int, width: int = 0, outline: int = None) -> None:
Validator.validate_color(color)
Validator.validate_pos(center, "center")
Validator.validate_int(radius, "radius")
Validator.validate_int(start_angle, "start_angle")
Validator.validate_int(end_angle, "end_angle")
cx, cy = center
start_rad = radians(start_angle)
end_rad = radians(end_angle)
step = radians(1)
points = [center]
angle = start_rad
while angle <= end_rad:
x = int(cx + cos(angle) * radius)
y = int(cy + sin(angle) * radius)
points.append((x, y))
angle += step
x_end = int(cx + cos(end_rad) * radius)
y_end = int(cy + sin(end_rad) * radius)
points.append((x_end, y_end))
Draw.polygon(color=color, points=tuple(points))
if outline is not None and width > 0:
Validator.validate_int(width, "width")
screen.set_width(width)
for i in range(1, len(points)-1):
x0, y0 = points[i]
x1, y1 = points[i+1]
screen.draw_line(x0=x0, y0=y0, x1=x1, y1=y1, color=outline)
x0, y0 = points[0]
x_start, y_start = points[1]
x_end, y_end = points[-1]
screen.draw_line(x0=x0, y0=y0, x1=x_start, y1=y_start, color=outline)
screen.draw_line(x0=x0, y0=y0, x1=x_end, y1=y_end, color=outline)
if width > 1:
screen.set_width(1)
@staticmethod
@micropython.native
def gradient_rect(pos: tuple, size: tuple, color_start: int, color_end: int, horizontal: bool = False) -> None:
Validator.validate_pos(pos, "pos")
Validator.validate_color(color_start)
Validator.validate_color(color_end)
x, y = pos
width, height = size
steps = width if horizontal else height
for i in range(steps):
ratio = i / steps
r = int(((color_end >> 16 & 0xFF) * ratio) + ((color_start >> 16 & 0xFF) * (1 - ratio)))
g = int(((color_end >> 8 & 0xFF) * ratio) + ((color_start >> 8 & 0xFF) * (1 - ratio)))
b = int(((color_end & 0xFF) * ratio) + ((color_start & 0xFF) * (1 - ratio)))
color = (r << 16) | (g << 8) | b
if horizontal:
Draw.line(color=color, start_pos=(x + i, y), end_pos=(x + i, y + height))
else:
Draw.line(color=color, start_pos=(x, y + i), end_pos=(x + width, y + i))
@staticmethod
def text(color: int, text: str, pos: tuple) -> None:
Validator.validate_color(color)
Validator.validate_string(text)
Validator.validate_pos(pos, "pos")
x, y = pos
screen.draw_text(text=text, x=x, y=y , color=color)
class Add:
"""
Provides various static utility methods for graphical drawing, including grid, arrow,
ruler, pie chart, gauge, and clock.
"""
RULER_SCALE_SHORT: int = micropython.const(5)
RULER_SCALE_LONG: int = micropython.const(10)
ARROW_HEAD_LENGTH: int = micropython.const(16)
ARROW_HEAD_WIDTH: int = micropython.const(10)
HOUR_LINES: int = micropython.const(6)
MINUTE_LINES: int = micropython.const(3)
@staticmethod
@micropython.native
def grid(color: int, pos: tuple, width: int, height: int, cell_size: int) -> None:
Validator.validate_color(color)
Validator.validate_pos(pos, "pos")
Validator.validate_int(width, "width")
Validator.validate_int(height, "height")
Validator.validate_int(cell_size, "cell_size")
if cell_size >= width:
raise ValueError(f"cell_size {cell_size} must be smaller {width}")
if cell_size >= height:
raise ValueError(f"cell_size {cell_size} must be smaller {height}")
x, y = pos
for i in range(0, width + 1, cell_size):
Draw.line(color=color, start_pos=(x + i, y), end_pos=(x + i, y + height))
for j in range(0, height + 1, cell_size):
Draw.line(color=color, start_pos=(x, y + j), end_pos=(x + width, y + j))
@staticmethod
@micropython.native
def arrow(color: int, start_pos: tuple, end_pos: tuple) -> None:
Validator.validate_color(color)
Validator.validate_pos(start_pos, "start_pos")
Validator.validate_pos(end_pos, "end_pos")
width: int = 2
x0, y0 = start_pos
x1, y1 = end_pos
Draw.line(color=color, start_pos=start_pos, end_pos=end_pos, width=width)
dx = x1 - x0
dy = y1 - y0
angle = radians(0) if dx == 0 and dy == 0 else atan2(dy, dx)
back_x = x1 - Add.ARROW_HEAD_LENGTH * cos(angle)
back_y = y1 - Add.ARROW_HEAD_LENGTH * sin(angle)
left_x = back_x + Add.ARROW_HEAD_WIDTH / 2 * sin(angle)
left_y = back_y - Add.ARROW_HEAD_WIDTH / 2 * cos(angle)
right_x = back_x - Add.ARROW_HEAD_WIDTH / 2 * sin(angle)
right_y = back_y + Add.ARROW_HEAD_WIDTH / 2 * cos(angle)
points = (
(x1, y1),
(int(left_x), int(left_y)),
(int(right_x), int(right_y))
)
Draw.polygon(color=color, points=points)
@staticmethod
@micropython.native
def ruler(color: int, orientation: str, pos: tuple, length: int, steps: int, long_steps: int = 5, invert: bool = False) -> None:
Validator.validate_color(color)
Validator.validate_pos(pos, "pos")
Validator.validate_int(length, "length")
Validator.validate_int(steps, "steps")
Validator.validate_int(long_steps, "long_steps")
if orientation not in ("horizontal", "vertical"):
raise ValueError("orientation must be 'horizontal' or 'vertical'")
x, y = pos
step_size = length // steps
if orientation == "horizontal":
Draw.line(color=color, start_pos=(x, y), end_pos=(x + length, y), width=1)
for i in range(steps + 1):
sx = x + i * step_size
step_length = Add.RULER_SCALE_LONG if i % long_steps == 0 else Add.RULER_SCALE_SHORT
if invert:
start_point = (sx, y)
end_point = (sx, y + step_length)
else:
start_point = (sx, y)
end_point = (sx, y - step_length)
Draw.line(color=color, start_pos=start_point, end_pos=end_point, width=1)
else:
Draw.line(color=color, start_pos=(x, y), end_pos=(x, y + length), width=1)
for i in range(steps + 1):
sy = y + i * step_size
step_length = Add.RULER_SCALE_LONG if i % long_steps == 0 else Add.RULER_SCALE_SHORT
if invert:
start_point = (x, sy)
end_point = (x - step_length, sy)
else:
start_point = (x, sy)
end_point = (x + step_length, sy)
Draw.line(color=color, start_pos=start_point, end_pos=end_point, width=1)
@staticmethod
def pie_chart(pos: tuple, radius: int, values: tuple, colors: tuple) -> None:
Validator.validate_pos(pos, "pos")
Validator.validate_int(radius, "radius")
if not (isinstance(values, tuple) and 1 <= len(values) <= 5):
raise ValueError("values must be a tuple with 1 to 5 elements")
total = sum(values)
if total != 100:
raise ValueError("values must sum to 100")
if not (isinstance(colors, tuple) and len(colors) == len(values)):
raise ValueError("colors must be a tuple matching the number of values")
for idx, color in enumerate(colors):
Validator.validate_color(color)
start_angle: int = 0
for value, color in zip(values, colors):
angle_span = int(value * 360 / 100)
end_angle = start_angle + angle_span
Draw.arc(color=color, center=pos, radius=radius, start_angle=start_angle, end_angle=end_angle)
start_angle = end_angle
@staticmethod
@micropython.native
def gauge(color: int, pos: tuple, value: int, value_range: tuple = (0, 100), radius: int = 50) -> None:
Validator.validate_color(color)
Validator.validate_pos(pos, "pos")
Validator.validate_int(value, "value")
Validator.validate_int(value_range[0], "range_min")
Validator.validate_int(value_range[1], "range_max")
Validator.validate_int(radius, "radius")
if not (value_range[0] <= value <= value_range[1]):
raise ValueError(f"value {value} out of range {range}")
width: int = 2
step: int = 1
angle_span: int = 180
x, y = pos
min_val, max_val = value_range
pointer_length = radius - 10
screen.set_width(width)
for angle in range(180, 361, step):
px = int(x + cos(radians(angle)) * radius)
py = int(y + sin(radians(angle)) * radius)
screen.draw_point(x=px, y=py, color=color)
if width > 1:
screen.set_width(1)
value_angle = 180 + ((value - min_val) / (max_val - min_val)) * angle_span
pointer_x = int(x + cos(radians(value_angle)) * pointer_length)
pointer_y = int(y + sin(radians(value_angle)) * pointer_length)
Draw.line(color=color, start_pos=pos, end_pos=(pointer_x, pointer_y), width=width)
Draw.circle(color=color, center=pos, radius=3)
@staticmethod
@micropython.native
def clock(color: int, center: tuple, radius: int, pointer: int, hour: int, minute: int) -> None:
Validator.validate_color(color)
Validator.validate_pos(center, "center")
Validator.validate_int(radius, "radius")
Validator.validate_color(pointer)
Validator.validate_int(hour, "hour")
Validator.validate_int(minute, "minute")
if not (0 <= hour < 24):
raise ValueError("hour must be int between 0 and 23")
if not (0 <= minute < 60):
raise ValueError("minute must be int between 0 and 59")
Draw.circle(color=color, center=center, radius=radius)
for i in range(12):
angle = radians(30 * i)
x_outer = center[0] + int(radius * cos(angle))
y_outer = center[1] + int(radius * sin(angle))
x_inner = center[0] + int((radius - Add.HOUR_LINES) * cos(angle))
y_inner = center[1] + int((radius - Add.HOUR_LINES) * sin(angle))
Draw.line(color=pointer, start_pos=(x_inner, y_inner), end_pos=(x_outer, y_outer), width=1)
if radius > 50:
for i in range(60):
angle = radians(6 * i)
x_outer = center[0] + int(radius * cos(angle))
y_outer = center[1] + int(radius * sin(angle))
x_inner = center[0] + int((radius - Add.MINUTE_LINES) * cos(angle))
y_inner = center[1] + int((radius - Add.MINUTE_LINES) * sin(angle))
Draw.line(color=pointer, start_pos=(x_inner, y_inner), end_pos=(x_outer, y_outer), width=1)
hour_angle = radians(30 * hour - 90)
hx = center[0] + int(radius * 0.5 * cos(hour_angle))
hy = center[1] + int(radius * 0.5 * sin(hour_angle))
Draw.line(color=pointer, start_pos=center, end_pos=(hx, hy), width=2)
min_angle = radians(6 * minute - 90)
mx = center[0] + int(radius * 0.8 * cos(min_angle))
my = center[1] + int(radius * 0.8 * sin(min_angle))
Draw.line(color=pointer, start_pos=center, end_pos=(mx, my), width=1)
class Screen:
"""
Represents the Screen functionality including initialization, background
manipulation, and rendering.
"""
def __init__(self, direction: int = 2, bg_color: int = BLACK):
screen.init(dir=direction)
screen.set_width(width=1)
screen.show_bg(color=bg_color)
self.draw = Draw()
self.add = Add()
@staticmethod
def show() -> None:
screen.show_draw()
@staticmethod
def clear(bg_color: int = None) -> None:
screen.clear()
if bg_color:
Validator.validate_color(bg_color)
screen.show_bg(color=bg_color)
Upload the following main.py examples into the root directory, create a new directory called lib, and inside this newly created folder upload ScreenExtension.py. After you are done, just run Unihiker K10.
Each demo structure should look like this:
root/
├── boot.py
├── main.py
├── lib/
│ └── ScreenExtension.py
Note: boot.py is not required, just optional.
Example 1:

from lib.ScreenExtension import Screen, RED, CYAN, BLACK, GREEN, BLUE, ORANGE
if __name__ == '__main__':
display = Screen(bg_color=BLACK)
display.draw.line(GREEN, (20, 10), (100, 100), 5)
display.draw.rect(CYAN, (150, 150, 50, 50), 6, BLUE)
display.draw.circle(ORANGE, (100, 200), 25, 4, RED)
display.show()
Example 2:

from lib.ScreenExtension import Screen, BLACK, YELLOW, BLUE, RED, GRAY, GREEN
if __name__ == '__main__':
display = Screen(bg_color=BLACK)
display.draw.polygon(YELLOW, ((20, 20), (100, 100), (40, 150)), 2, BLUE)
display.draw.arc(RED, (175, 100), 50, 0, 180, 3, GRAY)
display.draw.gradient_rect((100, 175), (100, 100), RED, GREEN, True)
display.show()
Example 3:

from lib.ScreenExtension import Screen, BLACK, WHITE, RED, BLUE, GREEN, ORANGE
if __name__ == '__main__':
display = Screen(bg_color=BLACK)
display.add.ruler(WHITE, "vertical", (20, 50), 200, 50, 10, False)
display.add.pie_chart((120, 160), 75, (40, 30, 10, 20), (RED, BLUE, GREEN, ORANGE))
display.add.ruler(WHITE, "vertical", (220, 50), 200, 50, 10, True)
display.show()
Example 4:

from lib.ScreenExtension import Screen, BLACK, PINK, YELLOW, RED, WHITE
if __name__ == '__main__':
display = Screen(bg_color=BLACK)
display.add.gauge(PINK, (120, 100), 75, (0, 100), 50)
display.add.arrow(YELLOW, (150, 150), (100, 125))
display.add.grid(RED, (20, 200), 50, 50, 5)
display.add.clock(WHITE, (170, 220), 50, BLACK, 8, 0)
display.show()
Example 5:
Here is an example of how to create a small animation without clear().
from micropython import const
from lib.ScreenExtension import Screen, WHITE, BLACK, BLUE
from time import sleep_ms
DELAY: int = const(25)
if __name__ == '__main__':
display = Screen(bg_color=BLACK)
start_y = 50
max_y = 200
step = 2
direction = 1
current_y = start_y
display.add.ruler(WHITE, "vertical", (15, 10), 200, 50, 10, False)
while True:
clear_region = (30, current_y - 10, 50 - 30 + 10, 55 - 45 + 20)
display.draw.rect(BLACK, clear_region)
polygon_points = (
(30, current_y),
(35, current_y - 5),
(50, current_y - 5),
(50, current_y + 5),
(35, current_y + 5),
)
display.draw.polygon(BLUE, polygon_points)
display.show()
current_y += step * direction
if current_y >= max_y or current_y <= start_y:
direction *= -1
sleep_ms(DELAY)
Want to take it further? Here are some sparks to ignite your creativity:
- Implement bezier curves or splines for smooth, artistic lines
- Create rounded rectangles for softer UI elements
- Add gradient fills for circles, lines, or radial gradients
- Add an icon class to support for quick UI elements
- Increase performance with the options listed here convert to *.mpy
