diff --git a/BasicFont.py b/BasicFont.py new file mode 100644 index 0000000..1cc6c65 --- /dev/null +++ b/BasicFont.py @@ -0,0 +1,201 @@ +# Font bitmaps, more slender than the font built into famebuf +# Source https://github.com/KahKitZheng/greenhouse/blob/master/raspberry_pi/grove.py/grove/display/sh1107g.py +BasicFont = [ + [0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00], + [0x00,0x00,0x5F,0x00,0x00,0x00,0x00,0x00], + [0x00,0x00,0x07,0x00,0x07,0x00,0x00,0x00], + [0x00,0x14,0x7F,0x14,0x7F,0x14,0x00,0x00], + [0x00,0x24,0x2A,0x7F,0x2A,0x12,0x00,0x00], + [0x00,0x23,0x13,0x08,0x64,0x62,0x00,0x00], + [0x00,0x36,0x49,0x55,0x22,0x50,0x00,0x00], + [0x00,0x00,0x05,0x03,0x00,0x00,0x00,0x00], + [0x00,0x1C,0x22,0x41,0x00,0x00,0x00,0x00], + [0x00,0x41,0x22,0x1C,0x00,0x00,0x00,0x00], + [0x00,0x08,0x2A,0x1C,0x2A,0x08,0x00,0x00], + [0x00,0x08,0x08,0x3E,0x08,0x08,0x00,0x00], + [0x00,0xA0,0x60,0x00,0x00,0x00,0x00,0x00], + [0x00,0x08,0x08,0x08,0x08,0x08,0x00,0x00], + [0x00,0x60,0x60,0x00,0x00,0x00,0x00,0x00], + [0x00,0x20,0x10,0x08,0x04,0x02,0x00,0x00], + [0x00,0x3E,0x51,0x49,0x45,0x3E,0x00,0x00], + [0x00,0x00,0x42,0x7F,0x40,0x00,0x00,0x00], + [0x00,0x62,0x51,0x49,0x49,0x46,0x00,0x00], + [0x00,0x22,0x41,0x49,0x49,0x36,0x00,0x00], + [0x00,0x18,0x14,0x12,0x7F,0x10,0x00,0x00], + [0x00,0x27,0x45,0x45,0x45,0x39,0x00,0x00], + [0x00,0x3C,0x4A,0x49,0x49,0x30,0x00,0x00], + [0x00,0x01,0x71,0x09,0x05,0x03,0x00,0x00], + [0x00,0x36,0x49,0x49,0x49,0x36,0x00,0x00], + [0x00,0x06,0x49,0x49,0x29,0x1E,0x00,0x00], + [0x00,0x00,0x36,0x36,0x00,0x00,0x00,0x00], + [0x00,0x00,0xAC,0x6C,0x00,0x00,0x00,0x00], + [0x00,0x08,0x14,0x22,0x41,0x00,0x00,0x00], + [0x00,0x14,0x14,0x14,0x14,0x14,0x00,0x00], + [0x00,0x41,0x22,0x14,0x08,0x00,0x00,0x00], + [0x00,0x02,0x01,0x51,0x09,0x06,0x00,0x00], + [0x00,0x32,0x49,0x79,0x41,0x3E,0x00,0x00], + [0x00,0x7E,0x09,0x09,0x09,0x7E,0x00,0x00], + [0x00,0x7F,0x49,0x49,0x49,0x36,0x00,0x00], + [0x00,0x3E,0x41,0x41,0x41,0x22,0x00,0x00], + [0x00,0x7F,0x41,0x41,0x22,0x1C,0x00,0x00], + [0x00,0x7F,0x49,0x49,0x49,0x41,0x00,0x00], + [0x00,0x7F,0x09,0x09,0x09,0x01,0x00,0x00], + [0x00,0x3E,0x41,0x41,0x51,0x72,0x00,0x00], + [0x00,0x7F,0x08,0x08,0x08,0x7F,0x00,0x00], + [0x00,0x41,0x7F,0x41,0x00,0x00,0x00,0x00], + [0x00,0x20,0x40,0x41,0x3F,0x01,0x00,0x00], + [0x00,0x7F,0x08,0x14,0x22,0x41,0x00,0x00], + [0x00,0x7F,0x40,0x40,0x40,0x40,0x00,0x00], + [0x00,0x7F,0x02,0x0C,0x02,0x7F,0x00,0x00], + [0x00,0x7F,0x04,0x08,0x10,0x7F,0x00,0x00], + [0x00,0x3E,0x41,0x41,0x41,0x3E,0x00,0x00], + [0x00,0x7F,0x09,0x09,0x09,0x06,0x00,0x00], + [0x00,0x3E,0x41,0x51,0x21,0x5E,0x00,0x00], + [0x00,0x7F,0x09,0x19,0x29,0x46,0x00,0x00], + [0x00,0x26,0x49,0x49,0x49,0x32,0x00,0x00], + [0x00,0x01,0x01,0x7F,0x01,0x01,0x00,0x00], + [0x00,0x3F,0x40,0x40,0x40,0x3F,0x00,0x00], + [0x00,0x1F,0x20,0x40,0x20,0x1F,0x00,0x00], + [0x00,0x3F,0x40,0x38,0x40,0x3F,0x00,0x00], + [0x00,0x63,0x14,0x08,0x14,0x63,0x00,0x00], + [0x00,0x03,0x04,0x78,0x04,0x03,0x00,0x00], + [0x00,0x61,0x51,0x49,0x45,0x43,0x00,0x00], + [0x00,0x7F,0x41,0x41,0x00,0x00,0x00,0x00], + [0x00,0x02,0x04,0x08,0x10,0x20,0x00,0x00], + [0x00,0x41,0x41,0x7F,0x00,0x00,0x00,0x00], + [0x00,0x04,0x02,0x01,0x02,0x04,0x00,0x00], + [0x00,0x80,0x80,0x80,0x80,0x80,0x00,0x00], + [0x00,0x01,0x02,0x04,0x00,0x00,0x00,0x00], + [0x00,0x20,0x54,0x54,0x54,0x78,0x00,0x00], + [0x00,0x7F,0x48,0x44,0x44,0x38,0x00,0x00], + [0x00,0x38,0x44,0x44,0x28,0x00,0x00,0x00], + [0x00,0x38,0x44,0x44,0x48,0x7F,0x00,0x00], + [0x00,0x38,0x54,0x54,0x54,0x18,0x00,0x00], + [0x00,0x08,0x7E,0x09,0x02,0x00,0x00,0x00], + [0x00,0x18,0xA4,0xA4,0xA4,0x7C,0x00,0x00], + [0x00,0x7F,0x08,0x04,0x04,0x78,0x00,0x00], + [0x00,0x00,0x7D,0x00,0x00,0x00,0x00,0x00], + [0x00,0x80,0x84,0x7D,0x00,0x00,0x00,0x00], + [0x00,0x7F,0x10,0x28,0x44,0x00,0x00,0x00], + [0x00,0x41,0x7F,0x40,0x00,0x00,0x00,0x00], + [0x00,0x7C,0x04,0x18,0x04,0x78,0x00,0x00], + [0x00,0x7C,0x08,0x04,0x7C,0x00,0x00,0x00], + [0x00,0x38,0x44,0x44,0x38,0x00,0x00,0x00], + [0x00,0xFC,0x24,0x24,0x18,0x00,0x00,0x00], + [0x00,0x18,0x24,0x24,0xFC,0x00,0x00,0x00], + [0x00,0x00,0x7C,0x08,0x04,0x00,0x00,0x00], + [0x00,0x48,0x54,0x54,0x24,0x00,0x00,0x00], + [0x00,0x04,0x7F,0x44,0x00,0x00,0x00,0x00], + [0x00,0x3C,0x40,0x40,0x7C,0x00,0x00,0x00], + [0x00,0x1C,0x20,0x40,0x20,0x1C,0x00,0x00], + [0x00,0x3C,0x40,0x30,0x40,0x3C,0x00,0x00], + [0x00,0x44,0x28,0x10,0x28,0x44,0x00,0x00], + [0x00,0x1C,0xA0,0xA0,0x7C,0x00,0x00,0x00], + [0x00,0x44,0x64,0x54,0x4C,0x44,0x00,0x00], + [0x00,0x08,0x36,0x41,0x00,0x00,0x00,0x00], + [0x00,0x00,0x7F,0x00,0x00,0x00,0x00,0x00], + [0x00,0x41,0x36,0x08,0x00,0x00,0x00,0x00], + [0x00,0x02,0x01,0x01,0x02,0x01,0x00,0x00], + [0x00,0x02,0x05,0x05,0x02,0x00,0x00,0x00] +] + +# Basic font(as above) with the last vertical line of empty data removed +# The condensed version of the font is 7 pixels wide instead of 8 (8x7) +BasicFontCondensed = [ + [0x00,0x00,0x00,0x00,0x00], + [0x00,0x5F,0x00,0x00,0x00], + [0x00,0x07,0x00,0x07,0x00], + [0x00,0x14,0x7F,0x14,0x7F,0x14,0x00], + [0x00,0x24,0x2A,0x7F,0x2A,0x12,0x00], + [0x00,0x23,0x13,0x08,0x64,0x62,0x00], + [0x00,0x36,0x49,0x55,0x22,0x50,0x00], + [0x00,0x05,0x03,0x00], + [0x00,0x1C,0x22,0x41,0x00], + [0x00,0x41,0x22,0x1C,0x00], + [0x00,0x08,0x2A,0x1C,0x2A,0x08,0x00], + [0x00,0x08,0x08,0x3E,0x08,0x08,0x00], + [0x00,0xA0,0x60,0x00], + [0x00,0x08,0x08,0x08,0x08,0x08,0x00], + [0x00,0x60,0x60,0x00], + [0x00,0x20,0x10,0x08,0x04,0x02,0x00], + [0x00,0x3E,0x51,0x49,0x45,0x3E,0x00], + [0x00,0x00,0x42,0x7F,0x40,0x00,0x00], + [0x00,0x62,0x51,0x49,0x49,0x46,0x00], + [0x00,0x22,0x41,0x49,0x49,0x36,0x00], + [0x00,0x18,0x14,0x12,0x7F,0x10,0x00], + [0x00,0x27,0x45,0x45,0x45,0x39,0x00], + [0x00,0x3C,0x4A,0x49,0x49,0x30,0x00], + [0x00,0x01,0x71,0x09,0x05,0x03,0x00], + [0x00,0x36,0x49,0x49,0x49,0x36,0x00], + [0x00,0x06,0x49,0x49,0x29,0x1E,0x00], + [0x00,0x36,0x36,0x00], + [0x00,0xAC,0x6C,0x00], + [0x00,0x08,0x14,0x22,0x41,0x00], + [0x00,0x14,0x14,0x14,0x14,0x14,0x00], + [0x00,0x41,0x22,0x14,0x08,0x00], + [0x00,0x02,0x01,0x51,0x09,0x06,0x00], + [0x00,0x32,0x49,0x79,0x41,0x3E,0x00], + [0x00,0x7E,0x09,0x09,0x09,0x7E,0x00], + [0x00,0x7F,0x49,0x49,0x49,0x36,0x00], + [0x00,0x3E,0x41,0x41,0x41,0x22,0x00], + [0x00,0x7F,0x41,0x41,0x22,0x1C,0x00], + [0x00,0x7F,0x49,0x49,0x49,0x41,0x00], + [0x00,0x7F,0x09,0x09,0x09,0x01,0x00], + [0x00,0x3E,0x41,0x41,0x51,0x72,0x00], + [0x00,0x7F,0x08,0x08,0x08,0x7F,0x00], + [0x00,0x41,0x7F,0x41,0x00], + [0x00,0x20,0x40,0x41,0x3F,0x01,0x00], + [0x00,0x7F,0x08,0x14,0x22,0x41,0x00], + [0x00,0x7F,0x40,0x40,0x40,0x40,0x00], + [0x00,0x7F,0x02,0x0C,0x02,0x7F,0x00], + [0x00,0x7F,0x04,0x08,0x10,0x7F,0x00], + [0x00,0x3E,0x41,0x41,0x41,0x3E,0x00], + [0x00,0x7F,0x09,0x09,0x09,0x06,0x00], + [0x00,0x3E,0x41,0x51,0x21,0x5E,0x00], + [0x00,0x7F,0x09,0x19,0x29,0x46,0x00], + [0x00,0x26,0x49,0x49,0x49,0x32,0x00], + [0x00,0x01,0x01,0x7F,0x01,0x01,0x00], + [0x00,0x3F,0x40,0x40,0x40,0x3F,0x00], + [0x00,0x1F,0x20,0x40,0x20,0x1F,0x00], + [0x00,0x3F,0x40,0x38,0x40,0x3F,0x00], + [0x00,0x63,0x14,0x08,0x14,0x63,0x00], + [0x00,0x03,0x04,0x78,0x04,0x03,0x00], + [0x00,0x61,0x51,0x49,0x45,0x43,0x00], + [0x00,0x7F,0x41,0x41,0x00], + [0x00,0x02,0x04,0x08,0x10,0x20,0x00], + [0x00,0x41,0x41,0x7F,0x00], + [0x00,0x04,0x02,0x01,0x02,0x04,0x00], + [0x00,0x80,0x80,0x80,0x80,0x80,0x00], + [0x00,0x01,0x02,0x04,0x00], + [0x00,0x20,0x54,0x54,0x54,0x78,0x00], + [0x00,0x7F,0x48,0x44,0x44,0x38,0x00], + [0x00,0x38,0x44,0x44,0x28,0x00], + [0x00,0x38,0x44,0x44,0x48,0x7F,0x00], + [0x00,0x38,0x54,0x54,0x54,0x18,0x00], + [0x00,0x08,0x7E,0x09,0x02,0x00], + [0x00,0x18,0xA4,0xA4,0xA4,0x7C,0x00], + [0x00,0x7F,0x08,0x04,0x04,0x78,0x00], + [0x00,0x7D,0x00], + [0x00,0x80,0x84,0x7D,0x00], + [0x00,0x7F,0x10,0x28,0x44,0x00], + [0x00,0x41,0x7F,0x40,0x00], + [0x00,0x7C,0x04,0x18,0x04,0x78,0x00], + [0x00,0x7C,0x08,0x04,0x7C,0x00], + [0x00,0x38,0x44,0x44,0x38,0x00], + [0x00,0xFC,0x24,0x24,0x18,0x00], + [0x00,0x18,0x24,0x24,0xFC,0x00], + [0x00,0x7C,0x08,0x04,0x00], + [0x00,0x48,0x54,0x54,0x24,0x00], + [0x00,0x04,0x7F,0x44,0x00], + [0x00,0x3C,0x40,0x40,0x7C,0x00], + [0x00,0x1C,0x20,0x40,0x20,0x1C,0x00], + [0x00,0x3C,0x40,0x30,0x40,0x3C,0x00], + [0x00,0x44,0x28,0x10,0x28,0x44,0x00], + [0x00,0x1C,0xA0,0xA0,0x7C,0x00], + [0x00,0x44,0x64,0x54,0x4C,0x44,0x00], + [0x00,0x08,0x36,0x41,0x00], + [0x00,0x7F,0x00], + [0x00,0x41,0x36,0x08,0x00], + [0x00,0x02,0x01,0x01,0x02,0x01,0x00], + [0x00,0x02,0x05,0x05,0x02,0x00] +] diff --git a/PicoOled13.py b/PicoOled13.py new file mode 100644 index 0000000..79233bf --- /dev/null +++ b/PicoOled13.py @@ -0,0 +1,434 @@ +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() diff --git a/main.py b/main.py index 2a0c617..59c9a12 100644 --- a/main.py +++ b/main.py @@ -1,44 +1,326 @@ import network -import socket +import ntptime import time +import weather_display import weather_requests +from PicoOled13 import get +from machine import Pin, RTC, Timer +import urequests as requests +import _thread -# --- WiFi Connection Setup --- -SSID = 'octopod' # Replace with your WiFi SSID -PASSWORD = 'amniotic-duo-portfolio' # Replace with your WiFi Password +# Weather API functions +def get_weather_data(url): + response = None + try: + response = requests.get(url) + if response.status_code == 200: + data = response.json() + return data + else: + print(f"Error: Status {response.status_code}") + print(response.text) + return None + except Exception as e: + print(f"Request failed: {e}") + return None + finally: + # Always close the response to free sockets + if response: + try: + response.close() + except: + pass -#Set current location + + + +# Global variables latitude = 50.9097 longitude = -1.4043 +today_forecast = None +current_view = "simple" +station = None # Global WiFi station +display = None # Global display object +weather_disp = None # Global weather display object +setup_complete = False # Flag to indicate if setup is complete +weather_update_in_progress = False # Flag to prevent multiple concurrent updates + +# WiFi credentials +SSID = 'octopod' +PASSWORD = 'amniotic-duo-portfolio' + +# Event flags for button presses +update_requested = False +view_change_requested = False -station = network.WLAN(network.STA_IF) -station.active(True) -station.connect(SSID, PASSWORD) -print("Connecting to WiFi...") +# Check WiFi and reconnect if needed +def ensure_wifi_connected(): + global station + + if not station.isconnected(): + print("WiFi disconnected, reconnecting...") + station.connect(SSID, PASSWORD) + + # Wait for connection with timeout + start_time = time.time() + while not station.isconnected(): + if time.time() - start_time > 15: # 15 second timeout + print("Failed to reconnect to WiFi") + return False + time.sleep(1) + + return True -timeout = 10 -start_time = time.time() -while not station.isconnected(): - if time.time() - start_time > timeout: - print("Failed to connect to WiFi. Check your SSID and password.") - break - time.sleep(1) +def update_weather(): + global today_forecast, weather_update_in_progress -if station.isconnected(): - ip = station.ipconfig("addr4") - time.sleep(1) - ipv6_addr = station.ipconfig("addr6") - print("Connected to WiFi! IPv4 address:", ip) - print("Connected to WiFi! IPv6 address:", ipv6_addr) -else: - print("WiFi connection not established. Restart and try again.") - raise SystemExit + + if weather_update_in_progress: + print("Weather update already in progress, ignoring request") + return + + weather_update_in_progress = True + + try: + import network + wlan = network.WLAN(network.STA_IF) + if not wlan.active(): + wlan.active(True) + if not wlan.isconnected(): + wlan.connect(SSID, PASSWORD) + print("Reconnecting WiFi from thread...") + start_time = time.time() + while not wlan.isconnected(): + if time.time() - start_time > 10: + print("Thread failed to connect WiFi") + display.auto_text("WiFi error (thread)") + return + time.sleep(0.5) + + display.auto_text("Updating weather...") + print("Thread WiFi connected. IP:", wlan.ifconfig()[0]) + + print("Fetching daily forecast...") + display.auto_text("Getting forecast...") + daily_data = weather_requests.get_daily(latitude, longitude) + if not daily_data: + print("Failed to get daily data") + display.auto_text("Error: daily data") + return + + print("Fetching hourly forecast...") + hourly_data = weather_requests.get_hourly(latitude, longitude) + if not hourly_data: + print("Failed to get hourly data") + display.auto_text("Error: hourly data") + return + + new_forecast = daily_data + new_forecast['current_temp'] = hourly_data['hourly']['temperature_2m'][0] + today_forecast = new_forecast + print("Weather data updated successfully") + + weather_disp.reset_display() + + + display.auto_text("Weather OK") + + except Exception as e: + print("Weather thread exception:", e) + display.auto_text("Update error") + + finally: + weather_update_in_progress = False + +def trigger_weather_update(): + _thread.start_new_thread(update_weather, ()) + +# Update display based on current data +def update_display(): + global display, weather_disp, today_forecast, current_view + + if today_forecast: + if current_view == "detailed": + weather_disp.display_weather(today_forecast) + else: + weather_disp.display_simple_weather(today_forecast) + else: + display.auto_text("Press KEY0 to update") -weather = weather_requests.get_daily(latitude, longitude) +def is_leap(year): + return year % 4 == 0 and (year % 100 != 0 or year % 400 == 0) -print(weather_requests.get_today_forecast(weather)) +def days_in_month(year, month): + if month == 2: + return 29 if is_leap(year) else 28 + return [31, 30, 31, 30, 31, 31, 30, 31, 30, 31, 30, 31][month - 1] + +def weekday(year, month, day): + # Zeller’s congruence (0=Saturday, 1=Sunday, ..., 6=Friday) + if month < 3: + month += 12 + year -= 1 + q = day + m = month + K = year % 100 + J = year // 100 + return (q + 13*(m + 1)//5 + K + K//4 + J//4 + 5*J) % 7 + +def last_sunday(year, month): + """Return (day, hour) of the last Sunday in a given month.""" + dim = days_in_month(year, month) + for d in range(dim, dim - 7, -1): + if weekday(year, month, d) == 1: # Sunday + return d + return None # should never happen + +def is_dst(year, month, day, hour=12): + """Returns True if the given UTC time is during UK DST (British Summer Time).""" + # DST starts last Sunday of March at 1:00 + march_last_sunday = last_sunday(year, 3) + dst_start = (3, march_last_sunday, 1) + + # DST ends last Sunday of October at 2:00 + oct_last_sunday = last_sunday(year, 10) + dst_end = (10, oct_last_sunday, 2) + + current = (month, day, hour) + + return dst_start <= current < dst_end + + +# Main loop (synchronous version) +def main_loop(): + global setup_complete, update_requested, view_change_requested + + # Wait for setup to complete before proceeding + while not setup_complete: + time.sleep(0.1) + + print("Main loop starting...") + display.auto_text("Ready. Press KEY0 to update") + + update_weather() # initial call + last_weather = time.ticks_ms() + WEATHER_MS = 10 * 60 * 1000 # 10 min in milliseconds + + while True: + update_display() + + # Has 10 minutes elapsed? + if time.ticks_diff(time.ticks_ms(), last_weather) >= WEATHER_MS: + update_weather() + last_weather = time.ticks_ms() + + time.sleep(0.5) + + + +# Setup function +def setup(): + global station, display, weather_disp, setup_complete + + # --- WiFi Connection Setup --- + station = network.WLAN(network.STA_IF) + station.active(True) + station.connect(SSID, PASSWORD) + print("Connecting to WiFi...") + + timeout = 15 # Extended timeout + start_time = time.time() + while not station.isconnected(): + if time.time() - start_time > timeout: + print("Failed to connect to WiFi. Check your SSID and password.") + break + time.sleep(1) + + if station.isconnected(): + ip = station.ifconfig()[0] # Standard way to get IP + print("Connected to WiFi! IP address:", ip) + + # Set up the RTC using NTP + rtc = RTC() + try: + print("Syncing time with NTP server...") + # Retry NTP time sync + for _ in range(3): + try: + ntptime.settime() + break + except: + print("NTP retry...") + time.sleep(1) + + # Get current time from RTC + current_time = list(rtc.datetime()) + year = current_time[0] + month = current_time[1] + day = current_time[2] + hour = current_time[4] + + # Adjust for UK time (UTC+0/+1) + if is_dst(year, month, day, hour): + print("British Summer Time (BST) is active - adding 1 hour") + current_time[4] += 1 + if current_time[4] >= 24: + current_time[4] -= 24 + current_time[2] += 1 + + # Update RTC with the adjusted time + rtc.datetime(tuple(current_time)) + print("RTC set with UK local time:", rtc.datetime()) + except OSError as e: + print("Error syncing time:", e) + else: + print("WiFi connection not established. Restart and try again.") + raise SystemExit + + # Initialize display + display = get() + weather_disp = weather_display.WeatherDisplay(display) + + # Setup button interrupts + button0 = Pin(display.KEY0, Pin.IN, Pin.PULL_UP) + button1 = Pin(display.KEY1, Pin.IN, Pin.PULL_UP) + button0.irq(trigger=Pin.IRQ_FALLING, handler=detailed_view) + button1.irq(trigger=Pin.IRQ_FALLING, handler=simple_view) + + # Display initial message + display.auto_text("Starting weather display...") + + # Set flag to indicate setup is complete + setup_complete = True + + print("Setup complete!") + + +def detailed_view(pin): + global current_view + print("Detailed view") + current_view = "detailed" + +def simple_view(pin): + global current_view + print("Simple view") + current_view = "simple" + +# Main function +def main(): + # Run setup + setup() + + # Start the main loop + main_loop() + + while True: + time.sleep(1) + + +# Run the main program +if __name__ == "__main__": + main() \ No newline at end of file diff --git a/weather_display.py b/weather_display.py new file mode 100644 index 0000000..0d97757 --- /dev/null +++ b/weather_display.py @@ -0,0 +1,262 @@ +import time +import uasyncio as asyncio + + +class WeatherDisplay: + def __init__(self, display): + self.display = display + self.text_padding = 4 + self.last_displayed_data = None + self.time_update_running = False + self.time_thread = None + + def weathercode_to_text(self, weathercode): + # More detailed weather code interpretations + if weathercode == 0: + return 'Clear Sky' + elif weathercode == 1: + return 'Mainly Clear' + elif weathercode == 2: + return 'Partly Cloudy' + elif weathercode == 3: + return 'Overcast' + elif weathercode in [45, 48]: + return 'Foggy' + elif weathercode in [51, 53, 55]: + return 'Light Rain' + elif weathercode in [56, 57]: + return 'Freezing Rain' + elif weathercode in [61, 63, 65]: + return 'Moderate Rain' + elif weathercode in [66, 67]: + return 'Heavy Rain' + elif weathercode in [71, 73, 75]: + return 'Snow Fall' + elif weathercode == 77: + return 'Snow Grains' + elif weathercode in [80, 81, 82]: + return 'Rain Showers' + elif weathercode in [85, 86]: + return 'Snow Showers' + elif weathercode == 95: + return 'Thunderstorm' + elif weathercode in [96, 99]: + return 'Thunderstorm with Hail' + else: + return 'Unknown Weather' + + + if force_full_update: + self.display.clear() + else: + # Create a clean buffer but don't send to display yet + self.display.fill(self.display.black) + + # Text starts at left padding + text_x = self.text_padding + text_y = 8 + + # Track regions that need updating + update_regions = [] + + # Display temperature + temp_text = f"Temp: {weather_data.get('max_temp', 0):.0f}/{weather_data.get('min_temp', 0):.0f}C" + if force_full_update or not self.last_displayed_data or \ + self.last_displayed_data.get('max_temp') != weather_data.get('max_temp') or \ + self.last_displayed_data.get('miget_wen_temp') != weather_data.get('min_temp'): + self.display.text(temp_text, text_x, text_y) + update_regions.append((0, text_y, 16, text_y + 8)) + + # Display weather description + weathercode = weather_data.get('weathercode', 0) + weather_text = self.weathercode_to_text(weathercode) + if force_full_update or not self.last_displayed_data or \ + self.last_displayed_data.get('weathercode') != weathercode: + self.display.text(f"{weather_text}", text_x, text_y + 12) + update_regions.append((0, text_y + 12, 16, text_y + 20)) + + # Display precipitation + precip_text = f"Precip: {weather_data.get('precip_mm', 0):.1f}mm" + if force_full_update or not self.last_displayed_data or \ + self.last_displayed_data.get('precip_mm') != weather_data.get('precip_mm'): + self.display.text(precip_text, text_x, text_y + 24) + update_regions.append((0, text_y + 24, 16, text_y + 32)) + + # Display date + date_text = weather_data.get('date', '') + if date_text and len(date_text) >= 10: + year = date_text[0:4] + month = date_text[5:7] + day = date_text[8:10] + date_text = f"{day}-{month}-{year}" + if force_full_update or not self.last_displayed_data or \ + self.last_displayed_data.get('date') != weather_data.get('date'): + self.display.text(f"Date: {date_text}", text_x, text_y + 36) + update_regions.append((0, text_y + 36, 16, text_y + 44)) + + # Store current data for future comparison + self.last_displayed_data = dict(weather_data) + self.last_displayed_data['view_type'] = 'detailed' + + # Update display - either full or partial updates + if force_full_update: + self.display.show() + else: + # Merge overlapping regions for more efficient updates + if update_regions: + # Simple approach: just update the full range covered by all regions + min_y = min(region[1] for region in update_regions) + max_y = max(region[3] for region in update_regions) + self.display.show(0, min_y, 16, max_y) + else: + # No changes detected, no need to update + pass + + + def display_weather(self, weather_data): + """Detailed view with max/min temps, description, precip, date.""" + # If this is the full API response, extract today's values: + if 'daily' in weather_data: + d = weather_data['daily'] + weather_data = { + 'max_temp': d['temperature_2m_max'][0], + 'min_temp': d['temperature_2m_min'][0], + 'weathercode':d['weathercode'][0], + 'precip_mm': d['precipitation_sum'][0], + 'date': d['time'][0], + } + + force_full_update = ( + self.last_displayed_data is None or + self.last_displayed_data.get('view_type') != 'detailed' + ) + + if force_full_update: + self.display.clear() + else: + self.display.fill(self.display.black) + + text_x = self.text_padding + text_y = 8 + update_regions = [] + + # Temperature + temp_text = f"Temp: {weather_data.get('max_temp', 0):.0f}/" \ + f"{weather_data.get('min_temp', 0):.0f}C" + if (force_full_update + or self.last_displayed_data.get('max_temp') != weather_data.get('max_temp') + or self.last_displayed_data.get('min_temp') != weather_data.get('min_temp') + ): + self.display.text(temp_text, text_x, text_y) + update_regions.append((text_y, text_y + 8)) + + # Description + code = weather_data.get('weathercode', 0) + desc = self.weathercode_to_text(code) + if (force_full_update + or self.last_displayed_data.get('weathercode') != code + ): + self.display.text(desc, text_x, text_y + 12) + update_regions.append((text_y + 12, text_y + 20)) + + # Precip + precip = f"Precip: {weather_data.get('precip_mm', 0):.1f}mm" + if (force_full_update + or self.last_displayed_data.get('precip_mm') != weather_data.get('precip_mm') + ): + self.display.text(precip, text_x, text_y + 24) + update_regions.append((text_y + 24, text_y + 32)) + + # Date (YYYY-MM-DD → DD-MM-YYYY) + raw_date = weather_data.get('date', '') + if raw_date and len(raw_date) >= 10: + dd = raw_date[8:10]; mm = raw_date[5:7]; yyyy = raw_date[0:4] + formatted = f"Date: {dd}-{mm}-{yyyy}" + if (force_full_update + or self.last_displayed_data.get('date') != raw_date + ): + self.display.text(formatted, text_x, text_y + 36) + update_regions.append((text_y + 36, text_y + 44)) + + # Save for next time + self.last_displayed_data = dict(weather_data) + self.last_displayed_data['view_type'] = 'detailed' + + # If full, just show everything + if force_full_update: + self.display.show() + return + + # Otherwise do a partial update over all pages + if update_regions: + min_y = min(r[0] for r in update_regions) + max_y = max(r[1] for r in update_regions) + page_count = self.display.height // 8 + self.display.show(1, min_y, page_count, max_y) + # else: nothing changed + + + + def display_simple_weather(self, weather_data): + force_full_update = self.last_displayed_data is None or self.last_displayed_data.get('view_type') != 'simple' + + if force_full_update: + self.display.clear() + else: + # Create a clean buffer but don't send to display yet + self.display.fill(self.display.black) + + # Text starts at left padding + text_x = self.text_padding + center_y = self.display.height // 2 + + # Track regions that need updating + update_regions = [] + + # Display temperature large + temp_text = f"{weather_data.get('current_temp', 0):.0f}C" + if force_full_update or not self.last_displayed_data or \ + self.last_displayed_data.get('current_temp') != weather_data.get('current_temp'): + self.display.text(temp_text, text_x, center_y - 8, scale=2) + update_regions.append((0, center_y - 8, 16, center_y + 8)) # Scaled text is 16px high + + # Display description + weather_desc = self.weathercode_to_text(weather_data.get('weathercode', 0)) + if force_full_update or not self.last_displayed_data or \ + self.last_displayed_data.get('weathercode') != weather_data.get('weathercode'): + self.display.text(weather_desc, text_x, center_y + 12) + update_regions.append((0, center_y + 12, 16, center_y + 20)) + + + + # Display current time + current_time_tuple = time.localtime(time.time()) + current_time = f"{current_time_tuple[3]:02d}:{current_time_tuple[4]:02d}:{current_time_tuple[5]:02d}" + if current_time and (force_full_update or not self.last_displayed_data or \ + self.last_displayed_data.get('current_time') != current_time): + self.display.text(current_time, text_x, center_y + 24) + update_regions.append((0, center_y + 24, 16, center_y + 32)) + + # Store current data for future comparison + self.last_displayed_data = dict(weather_data) + self.last_displayed_data['view_type'] = 'simple' + + # Update display - either full or partial updates + if force_full_update: + self.display.show() + else: + # Merge overlapping regions for more efficient updates + if update_regions: + # Simple approach: just update the full range covered by all regions + min_y = min(region[1] for region in update_regions) + max_y = max(region[3] for region in update_regions) + self.display.show(0, min_y, 16, max_y) + else: + # No changes detected, no need to update + pass + + def reset_display(self): + """Clear display and reset last displayed data.""" + self.display.clear() + self.display.show() + self.last_displayed_data = None \ No newline at end of file