diff --git a/DISPLAYS.md b/DISPLAYS.md index edcdafa..18ac0cb 100644 --- a/DISPLAYS.md +++ b/DISPLAYS.md @@ -48,6 +48,13 @@ I2C or SPI interfaces. An example is [Adafruit 938][4m]. The SSD1306 driver in this repo has a minor addition to enable it to support demos intended for color displays. +Monochrome OLED displays based on the SH1106 chip are supported via the +[unofficial driver](https://github.com/robert-hh/SH1106). +Displays are available from various sources and can use I2C or SPI interfaces. +An exmaple is the [Inland IIC SPI 1.3" 128x64 OLED V2.0](https://www.microcenter.com/product/643965/inland-iic-spi-13-128x64-oled-v20-graphic-display-module-for-arduino-uno-r3) +AKA [KeyStudio](https://wiki.keyestudio.com/Ks0056_keyestudio_1.3%22_128x64_OLED_Graphic_Display). + + Nokia 5110 (PCD8544) displays. [This driver](https://github.com/mcauser/micropython-pcd8544.git) is compatible. diff --git a/README.md b/README.md index ec449dd..719be37 100644 --- a/README.md +++ b/README.md @@ -113,6 +113,7 @@ my GUI's employ the American spelling of `color`. ## 1.1 Change log +12 Feb 2023 Add support for sh1106 driver. 5 Sep 2022 Add support for additional Pico displays. 8 Aug 2022 Typo and grammar fixes from @bfiics. 10 May 2022 Support Waveshare Pi Pico displays. @@ -252,12 +253,12 @@ The `gui/core` directory contains the GUI core and its principal dependencies: The `gui/demos` directory contains test/demo scripts. -Demos for small displays: - * `mono_test.py` Tests/demos using the official SSD1306 driver for a - monochrome 128*64 OLED display. +Demos for small displays: + * `mono_test.py` Tests/demos using the official SSD1306 or SH1106 driver for + monochrome 128*64 OLED displays. * `color96.py` Tests/demos for the Adafruit 0.96 inch color OLED. -Demos for larger displays. +Demos for larger displays. * `color15.py` Demonstrates a variety of widgets. Cross platform. * `aclock.py` Analog clock demo. Cross platform. * `alevel.py` Spirit level using Pyboard accelerometer. @@ -267,7 +268,7 @@ Demos for larger displays. * `asnano.py` Could readily be adapted for other targets. * `tbox.py` Demo `Textbox` class. Cross-platform. -Demos for ePaper displays: +Demos for ePaper displays: * `epd_async.py` Demo of asynchronous code on an eInk display. Needs a large display. * `epd29_sync.py` Demo for Adafruit 2.9" eInk display: emulates a seismograph. * `epd29_async.py` Asynchronous demo for Adafruit 2.9" eInk display. @@ -275,7 +276,7 @@ Demos for ePaper displays: [Micropower use](./DRIVERS.md#715-micropower-use) should be read before attempting to run this. -Demos for Sharp displays: +Demos for Sharp displays: * `sharptest.py` Basic functionality check. * `clocktest.py` Digital and analog clock demo. * `clock_batt.py` Low power demo of battery operated clock. @@ -317,6 +318,8 @@ copied to the hardware root as `color_setup.py`. Example files: * `ssd1306_pyb.py` Setup file for monochrome displays using the official driver. Supports hard or soft SPI or I2C connections. + * `ssd1106_spi_pico.py` Setup file for monochrome displays. + Supports hard or soft SPI or I2C connections. * `ssd1351_esp32.py` As written supports an ESP32 connected to a 128x128 SSD1351 display. After editing to match the display and wiring, it should be copied to the target as `/pyboard/color_setup.py`. @@ -342,11 +345,15 @@ to check for newer versions: Provides text rendering of Python font files. A copy of the official driver for OLED displays using the SSD1306 chip is -provided. The official file is here: +provided. The official file is here: * [SSD1306 driver](https://github.com/micropython/micropython/blob/master/drivers/display/ssd1306.py). +A copy of the unofficial driver for OLED displays using the SH1106 chip is +provided. The unofficial file is here: + * [Sh1106 driver](https://github.com/robert-hh/SH1106). + Displays based on the Nokia 5110 (PCD8544 chip) require this driver. It is not -in this repo but may be found here: +in this repo but may be found here: * [PCD8544/Nokia 5110](https://github.com/mcauser/micropython-pcd8544.git) ###### [Contents](./README.md#contents) @@ -404,11 +411,12 @@ from gui.widgets.label import Label # Import any widgets you plan to use from gui.widgets.dial import Dial, Pointer refresh(ssd, True) # Initialise and clear display. ``` -Initialisation of text display follows. For each font a `CWriter` instance -is created (for monochrome displays a `Writer` is used): + +Initialisation of color text display follows. For each font a `CWriter` instance +is created: ```python from gui.core.writer import CWriter # Renders color text -import gui.fonts.arial10 # A Python Font +from gui.fonts import arial10 # A Python Font from gui.core.colors import * # Standard color constants CWriter.set_textpos(ssd, 0, 0) # In case previous tests have altered it @@ -417,6 +425,19 @@ wri = CWriter(ssd, arial10, GREEN, BLACK, verbose=False) # Colors are defaults # wri = Writer(ssd, arial10, verbose=False) # Monochrome display uses Writer wri.set_clip(True, True, False) ``` + +Initialisation of monochorome text display follows. For each font a `Writer` instance +is created: +```python +from gui.core.writer import Writer # Renders color text +from gui.fonts import arial10 + +Writer.set_textpos(ssd, 0, 0) # In case previous tests have altered it +# Instantiate any Writers to be used (one for each font) +wri = Writer(ssd, arial10, verbose=False) # Monochrome display uses Writer +wri.set_clip(True, True, False) +``` + Calling `nanogui.refresh` on startup sets up and clears the display. The method will subsequently be called whenever a refresh is required. It takes two args: 1. `device` The display instance (the GUI supports multiple displays). @@ -488,7 +509,7 @@ Colors are handled flexibly. By default the colors used are those of the to warn of overrange or underrange values. The `color15.py` demo illustrates this. -Constructor args: +Constructor args: 1. `writer` The `Writer` instance (font and screen) to use. 2. `row` Location on screen. 3. `col` @@ -507,7 +528,7 @@ Constructor args: The constructor displays the string at the required location. -Methods: +Methods: 1. `value` Redraws the label. This takes the following args: * `text=None` The text to display. If `None` displays the last value. * ` invert=False` If true, show inverse text. @@ -520,7 +541,7 @@ Methods: Returns the current text string. 2. `show` No args. (Re)draws the label. Primarily for internal use by GUI. -Module Constants: +Module Constants: * `ALIGN_LEFT=0` * `ALIGN_RIGHT=1` * `ALIGN_CENTER=2` @@ -579,22 +600,22 @@ Keyword only args: `('0.0', '0.5', '1.0')` 14. `value=None` Initial value. If `None` the meter will not be drawn until its `value()` method is called. - + Methods: 1. `value` Args: `n=None, color=None`. * `n` should be a float in range 0 to 1.0. Causes the meter to be updated. Out of range values are constrained. If `None` is passed the meter is not updated. * `color` Updates the color of the bar or line if a value is also passed. - `None` causes no change. - Returns the current value. + `None` causes no change. + Returns the current value. 2. `text` Updates the label if present (otherwise throws a `ValueError`). Args: * `text=None` The text to display. If `None` displays the last value. * ` invert=False` If true, show inverse text. * `fgcolor=None` Foreground color: if `None` the `Writer` default is used. * `bgcolor=None` Background color, as per foreground. * `bdcolor=None` Border color. As per above except that if `False` is - passed, no border is displayed. This clears a previously drawn border. + passed, no border is displayed. This clears a previously drawn border. 3. `show` No args. (Re)draws the meter. Primarily for internal use by GUI. ###### [Contents](./README.md#contents) @@ -603,7 +624,7 @@ Methods: This is a virtual LED whose color may be altered dynamically. -Constructor positional args: +Constructor positional args: 1. `writer` The `Writer` instance (font and screen) to use. 2. `row` Location on screen. 3. `col` @@ -627,7 +648,7 @@ Methods: * `fgcolor=None` Foreground color: if `None` the `Writer` default is used. * `bgcolor=None` Background color, as per foreground. * `bdcolor=None` Border color. As per above except that if `False` is - passed, no border is displayed. This clears a previously drawn border. + passed, no border is displayed. This clears a previously drawn border. 3. `show` No args. (Re)draws the LED. Primarily for internal use by GUI. ###### [Contents](./README.md#contents) @@ -652,7 +673,7 @@ Pointer values are complex numbers. ### Dial class -Constructor positional args: +Constructor positional args: 1. `writer` The `Writer` instance (font and screen) to use. 2. `row` Location on screen. 3. `col` @@ -684,12 +705,12 @@ Constructor arg: 1. `dial` The `Dial` instance on which it is to be displayed. Methods: - 1. `value` Args: + 1. `value` Args: * `v=None` The value is a complex number. A magnitude exceeding unity is reduced (preserving phase) to constrain the `Pointer` within the unit circle. * `color=None` By default the pointer is rendered in the foreground color - of the parent `Dial`. Otherwise the passed color is used. + of the parent `Dial`. Otherwise the passed color is used. Returns the current value. 2. `show` No args. (Re)draws the control. Primarily for internal use by GUI. diff --git a/drivers/sh1106/sh1106.py b/drivers/sh1106/sh1106.py new file mode 100644 index 0000000..57dacce --- /dev/null +++ b/drivers/sh1106/sh1106.py @@ -0,0 +1,330 @@ +# Copied from https://github.com/robert-hh/SH1106 +# +# MicroPython SH1106 OLED driver, I2C and SPI interfaces +# +# The MIT License (MIT) +# +# Copyright (c) 2016 Radomir Dopieralski (@deshipu), +# 2017-2021 Robert Hammelrath (@robert-hh) +# 2021 Tim Weber (@scy) +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +# THE SOFTWARE. +# +# Sample code sections for ESP8266 pin assignments +# ------------ SPI ------------------ +# Pin Map SPI +# - 3v - xxxxxx - Vcc +# - G - xxxxxx - Gnd +# - D7 - GPIO 13 - Din / MOSI fixed +# - D5 - GPIO 14 - Clk / Sck fixed +# - D8 - GPIO 4 - CS (optional, if the only connected device) +# - D2 - GPIO 5 - D/C +# - D1 - GPIO 2 - Res +# +# for CS, D/C and Res other ports may be chosen. +# +# from machine import Pin, SPI +# import sh1106 + +# spi = SPI(1, baudrate=1000000) +# display = sh1106.SH1106_SPI(128, 64, spi, Pin(5), Pin(2), Pin(4)) +# display.sleep(False) +# display.fill(0) +# display.text('Testing 1', 0, 0, 1) +# display.show() +# +# --------------- I2C ------------------ +# +# Pin Map I2C +# - 3v - xxxxxx - Vcc +# - G - xxxxxx - Gnd +# - D2 - GPIO 5 - SCK / SCL +# - D1 - GPIO 4 - DIN / SDA +# - D0 - GPIO 16 - Res +# - G - xxxxxx CS +# - G - xxxxxx D/C +# +# Pin's for I2C can be set almost arbitrary +# +# from machine import Pin, I2C +# import sh1106 +# +# i2c = I2C(scl=Pin(5), sda=Pin(4), freq=400000) +# display = sh1106.SH1106_I2C(128, 64, i2c, Pin(16), 0x3c) +# display.sleep(False) +# display.fill(0) +# display.text('Testing 1', 0, 0, 1) +# display.show() + +from micropython import const +import utime as time +import framebuf + + +# a few register definitions +_SET_CONTRAST = const(0x81) +_SET_NORM_INV = const(0xA6) +_SET_DISP = const(0xAE) +_SET_SCAN_DIR = const(0xC0) +_SET_SEG_REMAP = const(0xA0) +_LOW_COLUMN_ADDRESS = const(0x00) +_HIGH_COLUMN_ADDRESS = const(0x10) +_SET_PAGE_ADDRESS = const(0xB0) + +# Subclassing FrameBuffer provides support for graphics primitives +# http://docs.micropython.org/en/latest/pyboard/library/framebuf.html +class SH1106(framebuf.FrameBuffer): + @staticmethod + def rgb(r, g, b): + return int((r > 127) or (g > 127) or (b > 127)) + + def __init__(self, width, height, external_vcc, rotate=0): + self.width = width + self.height = height + self.external_vcc = external_vcc + self.flip_en = rotate == 180 or rotate == 270 + self.rotate90 = rotate == 90 or rotate == 270 + self.pages = self.height // 8 + self.bufsize = self.pages * self.width + self.renderbuf = bytearray(self.bufsize) + self.pages_to_update = 0 + + if self.rotate90: + self.displaybuf = bytearray(self.bufsize) + # HMSB is required to keep the bit order in the render buffer + # compatible with byte-for-byte remapping to the display buffer, + # which is in VLSB. Else we'd have to copy bit-by-bit! + super().__init__( + self.renderbuf, self.height, self.width, framebuf.MONO_HMSB + ) + else: + self.displaybuf = self.renderbuf + super().__init__( + self.renderbuf, self.width, self.height, framebuf.MONO_VLSB + ) + + # flip() was called rotate() once, provide backwards compatibility. + self.rotate = self.flip + self.init_display() + + def init_display(self): + self.reset() + self.fill(0) + self.show() + self.poweron() + # rotate90 requires a call to flip() for setting up. + self.flip(self.flip_en) + + def poweroff(self): + self.write_cmd(_SET_DISP | 0x00) + + def poweron(self): + self.write_cmd(_SET_DISP | 0x01) + if self.delay: + time.sleep_ms(self.delay) + + def flip(self, flag=None, update=True): + if flag is None: + flag = not self.flip_en + mir_v = flag ^ self.rotate90 + mir_h = flag + self.write_cmd(_SET_SEG_REMAP | (0x01 if mir_v else 0x00)) + self.write_cmd(_SET_SCAN_DIR | (0x08 if mir_h else 0x00)) + self.flip_en = flag + if update: + self.show(True) # full update + + def sleep(self, value): + self.write_cmd(_SET_DISP | (not value)) + + def contrast(self, contrast): + self.write_cmd(_SET_CONTRAST) + self.write_cmd(contrast) + + def invert(self, invert): + self.write_cmd(_SET_NORM_INV | (invert & 1)) + + def show(self, full_update=False): + # self.* lookups in loops take significant time (~4fps). + (w, p, db, rb) = (self.width, self.pages, self.displaybuf, self.renderbuf) + if self.rotate90: + for i in range(self.bufsize): + db[w * (i % p) + (i // p)] = rb[i] + if full_update: + pages_to_update = (1 << self.pages) - 1 + else: + pages_to_update = self.pages_to_update + # print("Updating pages: {:08b}".format(pages_to_update)) + for page in range(self.pages): + if pages_to_update & (1 << page): + self.write_cmd(_SET_PAGE_ADDRESS | page) + self.write_cmd(_LOW_COLUMN_ADDRESS | 2) + self.write_cmd(_HIGH_COLUMN_ADDRESS | 0) + self.write_data(db[(w * page) : (w * page + w)]) + self.pages_to_update = 0 + + def pixel(self, x, y, color=None): + if color is None: + return super().pixel(x, y) + else: + super().pixel(x, y, color) + page = y // 8 + self.pages_to_update |= 1 << page + + def text(self, text, x, y, color=1): + super().text(text, x, y, color) + self.register_updates(y, y + 7) + + def line(self, x0, y0, x1, y1, color): + super().line(x0, y0, x1, y1, color) + self.register_updates(y0, y1) + + def hline(self, x, y, w, color): + super().hline(x, y, w, color) + self.register_updates(y) + + def vline(self, x, y, h, color): + super().vline(x, y, h, color) + self.register_updates(y, y + h - 1) + + def fill(self, color): + super().fill(color) + self.pages_to_update = (1 << self.pages) - 1 + + def blit(self, fbuf, x, y, key=-1, palette=None): + super().blit(fbuf, x, y, key, palette) + self.register_updates(y, y + self.height) + + def scroll(self, x, y): + # my understanding is that scroll() does a full screen change + super().scroll(x, y) + self.pages_to_update = (1 << self.pages) - 1 + + def fill_rect(self, x, y, w, h, color): + super().fill_rect(x, y, w, h, color) + self.register_updates(y, y + h - 1) + + def rect(self, x, y, w, h, color): + super().rect(x, y, w, h, color) + self.register_updates(y, y + h - 1) + + def register_updates(self, y0, y1=None): + # this function takes the top and optional bottom address of the changes made + # and updates the pages_to_change list with any changed pages + # that are not yet on the list + start_page = max(0, y0 // 8) + end_page = max(0, y1 // 8) if y1 is not None else start_page + # rearrange start_page and end_page if coordinates were given from bottom to top + if start_page > end_page: + start_page, end_page = end_page, start_page + for page in range(start_page, end_page + 1): + self.pages_to_update |= 1 << page + + def reset(self, res): + if res is not None: + res(1) + time.sleep_ms(1) + res(0) + time.sleep_ms(20) + res(1) + time.sleep_ms(20) + + +class SH1106_I2C(SH1106): + def __init__( + self, + width, + height, + i2c, + res=None, + addr=0x3C, + rotate=0, + external_vcc=False, + delay=0, + ): + self.i2c = i2c + self.addr = addr + self.res = res + self.temp = bytearray(2) + self.delay = delay + if res is not None: + res.init(res.OUT, value=1) + super().__init__(width, height, external_vcc, rotate) + + def write_cmd(self, cmd): + self.temp[0] = 0x80 # Co=1, D/C#=0 + self.temp[1] = cmd + self.i2c.writeto(self.addr, self.temp) + + def write_data(self, buf): + self.i2c.writeto(self.addr, b"\x40" + buf) + + def reset(self): + super().reset(self.res) + + +class SH1106_SPI(SH1106): + def __init__( + self, + width, + height, + spi, + dc, + res=None, + cs=None, + rotate=0, + external_vcc=False, + delay=0, + ): + dc.init(dc.OUT, value=0) + if res is not None: + res.init(res.OUT, value=0) + if cs is not None: + cs.init(cs.OUT, value=1) + self.spi = spi + self.dc = dc + self.res = res + self.cs = cs + self.delay = delay + super().__init__(width, height, external_vcc, rotate) + + def write_cmd(self, cmd): + if self.cs is not None: + self.cs(1) + self.dc(0) + self.cs(0) + self.spi.write(bytearray([cmd])) + self.cs(1) + else: + self.dc(0) + self.spi.write(bytearray([cmd])) + + def write_data(self, buf): + if self.cs is not None: + self.cs(1) + self.dc(1) + self.cs(0) + self.spi.write(buf) + self.cs(1) + else: + self.dc(1) + self.spi.write(buf) + + def reset(self): + super().reset(self.res) diff --git a/setup_examples/sh1106_spi_pico.py b/setup_examples/sh1106_spi_pico.py new file mode 100644 index 0000000..b68ada5 --- /dev/null +++ b/setup_examples/sh1106_spi_pico.py @@ -0,0 +1,15 @@ +from machine import Pin, SPI + +from drivers.sh1106.sh1106 import SH1106_SPI as SSD + +oled_width = 128 +oled_height = 64 +# Incorporating the Pico pin names into the variable names +sck_clk = Pin(14) +tx_mosi = Pin(15) +rx_miso_dc = Pin(12) +csn_cs = Pin(13) + +oled_spi = SPI(1, sck=sck_clk, mosi=tx_mosi, miso=rx_miso_dc) + +ssd = SSD(oled_width, oled_height, oled_spi, dc=rx_miso_dc, cs=csn_cs)