Includes weather data retrieval from Open-Meteo API and basic WiFi setup for connectivity. IntelliJ project configuration files and a `.gitignore` for IDE-specific files are also added.
435 lines
16 KiB
Python
435 lines
16 KiB
Python
from machine import Pin, SPI
|
|
import framebuf
|
|
import time
|
|
import BasicFont
|
|
|
|
# Pin Definitions
|
|
DC = 8
|
|
RST = 12
|
|
MOSI = 11
|
|
SCK = 10
|
|
CS = 9
|
|
|
|
KEY0 = 15
|
|
KEY1 = 17
|
|
|
|
# Display object
|
|
display = None
|
|
|
|
|
|
class OLED_1inch3_SPI(framebuf.FrameBuffer):
|
|
def __init__(self):
|
|
self.is_on = 0
|
|
self.width = 128
|
|
self.height = 64
|
|
|
|
self.white = 0xffff
|
|
self.black = 0x0000
|
|
|
|
self.font = BasicFont.BasicFontCondensed
|
|
|
|
# framebuf init
|
|
self.buffer = bytearray(self.height * self.width // 8)
|
|
super().__init__(self.buffer, self.width, self.height, framebuf.MONO_HMSB)
|
|
|
|
# SPI init
|
|
self.cs = Pin(CS, Pin.OUT)
|
|
self.rst = Pin(RST, Pin.OUT)
|
|
|
|
self.cs(1)
|
|
self.spi = SPI(1, 20000_000, polarity=0, phase=0, sck=Pin(SCK), mosi=Pin(MOSI), miso=None)
|
|
self.dc = Pin(DC, Pin.OUT)
|
|
self.dc(1)
|
|
|
|
# display init
|
|
self.init_display()
|
|
|
|
# Clear the screen on init - needs framebuf init, spi init and display init
|
|
self.clear()
|
|
|
|
# Init Keys
|
|
self.KEY0 = KEY0
|
|
self.KEY1 = KEY1
|
|
self.key0 = Pin(KEY0, Pin.IN, Pin.PULL_UP)
|
|
self.key1 = Pin(KEY1, Pin.IN, Pin.PULL_UP)
|
|
|
|
def is_pressed(self, key):
|
|
if key == self.KEY0:
|
|
return not self.key0.value()
|
|
elif key == self.KEY1:
|
|
return not self.key1.value()
|
|
else:
|
|
return None
|
|
|
|
def on(self):
|
|
if not self.is_on:
|
|
self.write_cmd(0xAF)
|
|
self.is_on = 1
|
|
|
|
def off(self):
|
|
if self.is_on:
|
|
self.write_cmd(0xAE)
|
|
self.is_on = 0
|
|
|
|
def get_width(self):
|
|
return self.width
|
|
|
|
def get_height(self):
|
|
return self.height
|
|
|
|
def write_cmd(self, cmd):
|
|
self.cs(1)
|
|
self.dc(0)
|
|
self.cs(0)
|
|
self.spi.write(bytearray([cmd]))
|
|
self.cs(1)
|
|
|
|
def write_data(self, buf):
|
|
self.cs(1)
|
|
self.dc(1)
|
|
self.cs(0)
|
|
self.spi.write(bytearray([buf]))
|
|
self.cs(1)
|
|
|
|
def init_display(self):
|
|
"""Initialize display"""
|
|
self.rst(1)
|
|
time.sleep(0.001)
|
|
self.rst(0)
|
|
time.sleep(0.01)
|
|
self.rst(1)
|
|
|
|
self.off()
|
|
|
|
self.write_cmd(0x00) # set lower column address
|
|
self.write_cmd(0x10) # set higher column address
|
|
|
|
self.write_cmd(0xB0) # set page address
|
|
|
|
self.write_cmd(0xdc) # set display start line
|
|
self.write_cmd(0x00) # (2nd param)
|
|
|
|
self.write_cmd(0x81) # contract control
|
|
self.write_cmd(0x80) # 128
|
|
|
|
self.write_cmd(0x21) # Set Memory addressing mode (0x20/0x21)
|
|
self.write_cmd(0xa0) # set segment remap
|
|
self.write_cmd(0xc0) # Com scan direction
|
|
self.write_cmd(0xa4) # Disable Entire Display On (0xA4/0xA5)
|
|
self.write_cmd(0xa6) # normal / reverse
|
|
|
|
self.write_cmd(0xa8) # multiplex ratio ??
|
|
self.write_cmd(0x3f) # duty = 1/64 ??
|
|
|
|
self.write_cmd(0xd3) # set display offset
|
|
self.write_cmd(0x60)
|
|
|
|
self.write_cmd(0xd5) # set osc division
|
|
self.write_cmd(0x50)
|
|
|
|
self.write_cmd(0xd9) # set pre-charge period
|
|
self.write_cmd(0x22)
|
|
|
|
self.write_cmd(0xdb) # set vcomh
|
|
self.write_cmd(0x35)
|
|
|
|
self.write_cmd(0xad) # set charge pump enable
|
|
self.write_cmd(0x8a) # Set DC-DC enable (a=0:disable; a=1:enable)
|
|
|
|
self.on()
|
|
|
|
# --- Helper functions for scaling bitmaps ---
|
|
def _unpack_bitmap(self, data, width, height):
|
|
"""
|
|
Unpack a MONO_VLSB bitmap into a 2D list of 0s and 1s.
|
|
Each byte in data represents one vertical column of 8 pixels.
|
|
"""
|
|
arr = [[0] * width for _ in range(height)]
|
|
for x in range(width):
|
|
b = data[x]
|
|
for y in range(height):
|
|
arr[y][x] = 1 if (b >> y) & 1 else 0
|
|
return arr
|
|
|
|
def _scale_2d_array(self, arr, scale):
|
|
"""
|
|
Scale a 2D list (of 0s and 1s) by an integer factor using pixel replication.
|
|
"""
|
|
h = len(arr)
|
|
w = len(arr[0])
|
|
new_arr = [[0] * (w * scale) for _ in range(h * scale)]
|
|
for y in range(h):
|
|
for x in range(w):
|
|
pixel = arr[y][x]
|
|
for dy in range(scale):
|
|
for dx in range(scale):
|
|
new_arr[y * scale + dy][x * scale + dx] = pixel
|
|
return new_arr
|
|
|
|
def _pack_bitmap(self, arr):
|
|
"""
|
|
Pack a 2D list of 0s and 1s into a MONO_VLSB bytearray.
|
|
The height must be a multiple of 8.
|
|
"""
|
|
height = len(arr)
|
|
width = len(arr[0])
|
|
buf = bytearray(width * (height // 8))
|
|
for x in range(width):
|
|
for byte_row in range(height // 8):
|
|
b = 0
|
|
for bit in range(8):
|
|
y = byte_row * 8 + bit
|
|
if arr[y][x]:
|
|
b |= (1 << bit)
|
|
buf[x + byte_row * width] = b
|
|
return buf
|
|
|
|
def _scale_bitmap(self, data, width, height, scale):
|
|
"""
|
|
Scale a MONO_VLSB bitmap (provided as a bytearray) by an integer factor.
|
|
Returns the scaled bytearray and the new dimensions.
|
|
"""
|
|
arr = self._unpack_bitmap(data, width, height)
|
|
scaled_arr = self._scale_2d_array(arr, scale)
|
|
new_buffer = self._pack_bitmap(scaled_arr)
|
|
new_width = width * scale
|
|
new_height = height * scale
|
|
return new_buffer, new_width, new_height
|
|
|
|
# --- Modified text() method with scaling support ---
|
|
# The original text is assumed to be 8 pixels high.
|
|
def text(self, s, x0, y0, col=0xffff, wrap=1, just=0, scale=1):
|
|
"""
|
|
Draw text on the display starting at (x0, y0).
|
|
|
|
Parameters:
|
|
s : The string to render.
|
|
x0, y0 : Starting coordinates.
|
|
col : Color (default white). If 0, the text is inverted.
|
|
wrap : Text wrapping mode.
|
|
0: Clip at the right edge.
|
|
1: Wrap to the next line.
|
|
Other: Wrap to the original x0 coordinate.
|
|
just : Justification.
|
|
0: Left-justified.
|
|
1: Right-justified.
|
|
2: Center-justified.
|
|
scale : Scaling factor for enlarging the font (default is 1, i.e. no scaling).
|
|
|
|
Returns:
|
|
A list [new_x, new_y] with updated coordinates after rendering the text.
|
|
"""
|
|
if len(s) == 0:
|
|
return (x0, y0)
|
|
|
|
x = x0
|
|
pixels = bytearray([])
|
|
# For each character in the string, accumulate its bitmap data
|
|
for i in range(len(s)):
|
|
C = ord(s[i])
|
|
if C < 32 or C > 127:
|
|
C = 32
|
|
cdata = self.font[C - 32]
|
|
effective_char_width = len(cdata) * scale
|
|
effective_pixels_width = len(pixels) * scale
|
|
# Check if adding the next character would exceed the display width
|
|
if len(pixels) and (
|
|
(just == 0 and x + effective_pixels_width + effective_char_width > self.width) or
|
|
(just == 1 and x - effective_pixels_width - effective_char_width < 0) or
|
|
(just == 2 and (x - effective_pixels_width / 2 - effective_char_width < 0 or
|
|
x + effective_pixels_width / 2 + effective_char_width > self.width))
|
|
):
|
|
# Invert pixels if needed
|
|
if col == 0:
|
|
for j, v in enumerate(pixels):
|
|
pixels[j] = 0xFF & ~v
|
|
# Create a frame buffer from the accumulated data; scale if needed
|
|
if scale == 1:
|
|
fb = framebuf.FrameBuffer(pixels, len(pixels), 8, framebuf.MONO_VLSB)
|
|
else:
|
|
scaled_buf, new_w, new_h = self._scale_bitmap(pixels, len(pixels), 8, scale)
|
|
fb = framebuf.FrameBuffer(scaled_buf, new_w, new_h, framebuf.MONO_VLSB)
|
|
if just == 0:
|
|
self.blit(fb, x, y0)
|
|
elif just == 1:
|
|
self.blit(fb, x - (len(pixels) * scale), y0)
|
|
else:
|
|
self.blit(fb, x - int((len(pixels) * scale) / 2), y0)
|
|
pixels = bytearray([])
|
|
if wrap == 0:
|
|
return [x, y0 + (8 * scale) + 1]
|
|
if wrap == 1:
|
|
x = 0
|
|
else:
|
|
x = x0
|
|
y0 = y0 + (8 * scale) + 1
|
|
if y0 > self.height:
|
|
return [x, y0]
|
|
# Accumulate character bitmap data (unscaled)
|
|
pixels += bytearray(cdata)
|
|
|
|
# Render any remaining accumulated text
|
|
if col == 0:
|
|
for j, v in enumerate(pixels):
|
|
pixels[j] = 0xFF & ~v
|
|
if scale == 1:
|
|
fb = framebuf.FrameBuffer(pixels, len(pixels), 8, framebuf.MONO_VLSB)
|
|
else:
|
|
scaled_buf, new_w, new_h = self._scale_bitmap(pixels, len(pixels), 8, scale)
|
|
fb = framebuf.FrameBuffer(scaled_buf, new_w, new_h, framebuf.MONO_VLSB)
|
|
if just == 0:
|
|
self.blit(fb, x, y0)
|
|
elif just == 1:
|
|
self.blit(fb, x - (len(pixels) * scale), y0)
|
|
else:
|
|
self.blit(fb, x - int((len(pixels) * scale) / 2), y0)
|
|
return [x, y0 + (8 * scale) + 1]
|
|
|
|
# Shows the framebuffer contents on the display.
|
|
# If no arguments are given, the full frame buffer is sent to the display.
|
|
# startXPage: The horizontal page index to start the update. The X-axis is divided in 16 8-pixel 'pages' (0 ~ 15)
|
|
# startYLine: The vertical line index to start the display update (0 ~ 63)
|
|
# endXPage: The horizontal page index to end the update (excluding that page index) (1 ~ 16)
|
|
# endYLine: The vertical line index to end the display update (excluding that line index) (1 ~ 64)
|
|
def show(self, startXPage=0, startYLine=0, endXPage=16, endYLine=64):
|
|
self.__validateShowArguments(startXPage, startYLine, endXPage, endYLine)
|
|
doCustomPageAddressing = startXPage > 0 or endXPage < 16
|
|
if not doCustomPageAddressing:
|
|
self.write_cmd(0xB0)
|
|
for yLine in range(startYLine, endYLine):
|
|
columnSramAddress = 63 - yLine
|
|
self.write_cmd(0x00 + (columnSramAddress & 0x0f))
|
|
self.write_cmd(0x10 + (columnSramAddress >> 4))
|
|
if doCustomPageAddressing:
|
|
self.write_cmd(0xB0 + (startXPage & 0x0f))
|
|
for num in range(startXPage, endXPage):
|
|
self.write_data(self.buffer[yLine * 16 + num])
|
|
|
|
def __validateShowArguments(self, startXPage, startYLine, endXPage, endYLine):
|
|
if not startYLine < endYLine:
|
|
raise IndexError("show(...): startYLine (" + str(startYLine) +
|
|
") must be smaller than endYLine (" + str(endYLine) + ").")
|
|
if startYLine < 0 or startYLine > 63:
|
|
raise IndexError("show(...): startYLine acceptable range is 0 ~ 63. Given: " + str(startYLine))
|
|
if endYLine < 1 or endYLine > 64:
|
|
raise IndexError("show(...): endYLine acceptable range is 1 ~ 64. Given: " + str(endYLine))
|
|
if not startXPage < endXPage:
|
|
raise IndexError("show(...): startXPage (" + str(startXPage) +
|
|
") must be smaller than endXPage (" + str(endXPage) + ").")
|
|
if startXPage < 0 or startXPage > 15:
|
|
raise IndexError("show(...): startXPage acceptable range is 0 ~ 15. Given: " + str(startXPage))
|
|
if endXPage < 1 or endXPage > 16:
|
|
raise IndexError("show(...): endXPage acceptable range is 1 ~ 16. Given: " + str(endXPage))
|
|
|
|
def auto_text(self, s, col=0xffff):
|
|
"""
|
|
Automatically determine the largest integer scale factor such that the full
|
|
string (with whole words kept on the same line) fits on the screen,
|
|
and render it centered.
|
|
|
|
Parameters:
|
|
s : The text string to render.
|
|
col : Color (default white).
|
|
"""
|
|
# Maximum possible scale based on unscaled height (8 pixels per line)
|
|
max_possible_scale = self.height // 8
|
|
space_width = 3 # unscaled space width in pixels
|
|
|
|
# Try scales from the largest down to 1
|
|
for scale in range(max_possible_scale, 0, -1):
|
|
words = s.split(' ')
|
|
scale_valid = True
|
|
# Check that every individual word fits on one line at this scale
|
|
for word in words:
|
|
# Compute the word width using the font data
|
|
word_width = sum(len(self.font[ord(c) - 32]) * scale for c in word if 32 <= ord(c) <= 127)
|
|
if word_width > self.width:
|
|
scale_valid = False
|
|
break
|
|
if not scale_valid:
|
|
continue # Try a smaller scale
|
|
|
|
# Build lines without breaking words.
|
|
lines = []
|
|
current_line_words = []
|
|
current_line_width = 0
|
|
for word in words:
|
|
# Calculate word width at current scale.
|
|
word_width = sum(len(self.font[ord(c) - 32]) * scale for c in word if 32 <= ord(c) <= 127)
|
|
additional_space = space_width * scale if current_line_words else 0
|
|
if current_line_words and (current_line_width + additional_space + word_width > self.width):
|
|
# Commit current line and start a new one
|
|
lines.append(" ".join(current_line_words))
|
|
current_line_words = [word]
|
|
current_line_width = word_width
|
|
else:
|
|
if current_line_words:
|
|
current_line_width += additional_space + word_width
|
|
else:
|
|
current_line_width += word_width
|
|
current_line_words.append(word)
|
|
if current_line_words:
|
|
lines.append(" ".join(current_line_words))
|
|
|
|
# Check total height: each line is (8*scale) pixels high with a 1-pixel gap between lines.
|
|
total_height = len(lines) * (8 * scale) + (len(lines) - 1)
|
|
if total_height <= self.height:
|
|
# This scale works
|
|
break
|
|
|
|
# Center the text vertically.
|
|
total_text_height = len(lines) * (8 * scale) + (len(lines) - 1)
|
|
y_offset = (self.height - total_text_height) // 2
|
|
|
|
self.clear()
|
|
|
|
# Render each line centered horizontally.
|
|
for line in lines:
|
|
line_width = 0
|
|
for char in line:
|
|
if char == ' ':
|
|
line_width += space_width * scale
|
|
else:
|
|
C = ord(char)
|
|
if C < 32 or C > 127:
|
|
C = 32
|
|
line_width += len(self.font[C - 32]) * scale
|
|
x_offset = (self.width - line_width) // 2
|
|
self.text(line, x_offset, y_offset, col, wrap=0, just=0, scale=scale)
|
|
y_offset += (8 * scale) + 1
|
|
|
|
def clear(self):
|
|
self.fill(self.black)
|
|
self.show()
|
|
|
|
|
|
|
|
def get():
|
|
global display
|
|
if display is None:
|
|
display = OLED_1inch3_SPI()
|
|
return display
|
|
|
|
|
|
def test():
|
|
display = get()
|
|
display.clear()
|
|
print("Running display tests")
|
|
|
|
# Test sequence to validate Xpage and YLine writing to the display
|
|
display.fill(1)
|
|
display.show()
|
|
display.fill(0)
|
|
display.show()
|
|
|
|
# Fill the framebuffer with white in specific regions:
|
|
display.fill(1)
|
|
display.show(0, 0, 16, 8) # Top horizontal bar
|
|
display.show(0, 56, 16, 64) # Bottom horizontal bar
|
|
display.show(0, 8, 1, 56) # Vertical bar on the left, partially drawn
|
|
display.show(7, 24, 9, 40) # 16 x 16 pixel square in the middle
|
|
|
|
|
|
if __name__ == '__main__':
|
|
test()
|