diff --git a/README.md b/README.md index ac1368a..738507a 100644 --- a/README.md +++ b/README.md @@ -19,6 +19,9 @@ to a wide range of displays. It is also portable between hosts. ![Image](./images/ttgo.JPG) TTGO T-Display. Add a joystick switch and an SIL resistor for a simple, inexpensive, WiFi capable system. +An alternative interface consists of two pushbuttons and an encoder such as +[this one](https://www.adafruit.com/product/377). + # Rationale Touch GUI's have many advantages, however they have drawbacks, principally cost @@ -115,7 +118,7 @@ of some display drivers.      22.3.1 [Class Curve](./README.md#2231-class-curve)      22.3.2 [Class PolarCurve](./README.md#2232-class-polarcurve) 22.4 [Class TSequence](./README.md#224-class-tsequence) Plotting realtime, time sequential data. -[Appendix 1 Application design](./README.md#appendix-1-application-design) Tab order, button layout, use of graphics primitives +[Appendix 1 Application design](./README.md#appendix-1-application-design) Tab order, button layout, encoder interface, use of graphics primitives # 1. Basic concepts @@ -385,6 +388,8 @@ files in `gui/core` are: The `gui/primitives` directory contains the following files: * `switch.py` Interface to physical pushbuttons. * `delay_ms.py` A software triggerable timer. + * `encoder.py` Driver for a quadrature encoder. This offers an alternative + interface - see [Appendix 1](./README.md#appendix-1-application-design). The `gui/demos` directory contains a variety of demos and tests described below. @@ -2202,6 +2207,16 @@ The apparently obvious solution of designing a vertical `Scale` is tricky owing to the fact that the length of the internal text can be substantial and variable. +## Encoder interface + +This alternative interface comprises just two buttons `Next` and `Prev`. +Selection and Increase/Decrease is handled by an encoder such as +[this one](https://www.adafruit.com/product/377). Selection occurs when the +knob is pressed, and movement when it is rotated. This is more intuitive, +particularly with horizontally oriented controls. + +TODO wiring details. + ## Screen layout Widgets are positioned using absolute `row` and `col` coordinates. These may diff --git a/gui/core/ugui.py b/gui/core/ugui.py index f19b7f7..28a21d4 100644 --- a/gui/core/ugui.py +++ b/gui/core/ugui.py @@ -38,15 +38,15 @@ _LAST = const(3) # Wrapper for ssd providing buttons and framebuf compatible methods class Display: - def __init__(self, objssd, nxt, sel, prev=None, up=None, down=None): + def __init__(self, objssd, nxt, sel, prev=None, up=None, down=None, encoder=False): global display, ssd self._next = Switch(nxt) self._sel = Switch(sel) self._last = None # Last switch pressed. # Mandatory buttons # Call current screen bound method - self._next.close_func(self._closure, (self._next, Screen.next_ctrl)) - self._sel.close_func(self._closure, (self._sel, Screen.sel_ctrl)) + self._next.close_func(self._closure, (self._next, Screen.ctrl_move, _NEXT)) + self._sel.close_func(self._closure, (self._sel, Screen.sel_ctrl, 0)) self._sel.open_func(Screen.unsel) self.height = objssd.height @@ -56,26 +56,30 @@ class Display: self._prev = None if prev is not None: self._prev = Switch(prev) - self._prev.close_func(self._closure, (self._prev, Screen.prev_ctrl)) - # Up and down methods get the button as an arg. - self._up = None - if up is not None: - self._up = Switch(up) - self._up.close_func(self._closure, (self._up, self.do_up)) - self._down = None - if down is not None: - self._down = Switch(down) - self._down.close_func(self._closure, (self._down, self.do_down)) + self._prev.close_func(self._closure, (self._prev, Screen.ctrl_move, _PREV)) + if encoder: + if up is None or down is None: + raise ValueError('Must specify pins for encoder.') + from gui.primitives.encoder import Encoder + self._enc = Encoder(up, down, div=encoder, callback=Screen.adjust) + else: + # Up and down methods get the button as an arg. + if up is not None: + sup = Switch(up) + sup.close_func(self._closure, (sup, Screen.adjust, 1)) + if down is not None: + sdown = Switch(down) + sdown.close_func(self._closure, (sdown, Screen.adjust, -1)) self._is_grey = False # Not greyed-out display = self # Populate globals ssd = objssd # Reject button presses where a button is already pressed. # Execute if initialising, if same switch re-pressed or if last switch released - def _closure(self, switch, func): + def _closure(self, switch, func, arg): if (self._last is None) or (self._last == switch) or self._last(): self._last = switch - func() + func(switch, arg) def print_centred(self, writer, x, y, text, fgcolor=None, bgcolor=None, invert=False): sl = writer.stringlen(text) @@ -94,12 +98,6 @@ class Display: writer.printstring(txt, invert) writer.setcolor() # Restore defaults - def do_up(self): - Screen.up_ctrl(self._up) - - def do_down(self): - Screen.down_ctrl(self._down) - # Greying out has only one option given limitation of 4-bit display driver # It would be possible to do better with RGB565 but would need inverse transformation # to (r, g, b), scale and re-convert to integer. @@ -204,35 +202,27 @@ class Screen: is_shutdown = Event() @classmethod - def next_ctrl(cls): + def ctrl_move(cls, _, v): if cls.current_screen is not None: - cls.current_screen.move(_NEXT) + cls.current_screen.move(v) @classmethod - def prev_ctrl(cls): - if cls.current_screen is not None: - cls.current_screen.move(_PREV) - - @classmethod - def sel_ctrl(cls): + def sel_ctrl(cls, b, _): if cls.current_screen is not None: cls.current_screen.do_sel() - @classmethod def unsel(cls): if cls.current_screen is not None: cls.current_screen.unsel_i() + # Adjust the value of a widget. If an encoder is used, button arg + # is an int (discarded), val is the delta. If using buttons, 1st + # arg is the button, delta is +1 or -1 @classmethod - def up_ctrl(cls, button): + def adjust(cls, button, val): if cls.current_screen is not None: - cls.current_screen.do_up(button) - - @classmethod - def down_ctrl(cls, button): - if cls.current_screen is not None: - cls.current_screen.do_down(button) + cls.current_screen.do_adj(button, val) # Move currency to a specific widget (e.g. ButtonList) @classmethod @@ -440,19 +430,12 @@ class Screen: if co is not None: co.unsel() - def do_up(self, button): + def do_adj(self, button, val): co = self.get_obj() - if co is not None and hasattr(co, 'do_up'): - co.do_up(button) # Widget handles up/down + if co is not None and hasattr(co, 'do_adj'): + co.do_adj(button, val) # Widget can handle up/down else: - Screen.current_screen.move(_FIRST) - - def do_down(self, button): - co = self.get_obj() - if co is not None and hasattr(co, 'do_down'): - co.do_down(button) - else: - Screen.current_screen.move(_LAST) + Screen.current_screen.move(_FIRST if val < 0 else _LAST) # Methods optionally implemented in subclass def on_open(self): @@ -710,21 +693,17 @@ class LinearIO(Widget): # but subclass can be defeat this with WHITE or another color self.prcolor = YELLOW if prcolor is None else prcolor - def do_up(self, button): - asyncio.create_task(self.btnhan(button, 1)) - - def do_down(self, button): - asyncio.create_task(self.btnhan(button, -1)) + # Adjust widget's value. Args: button pressed, amount of increment + def do_adj(self, button, val): + encoder = isinstance(button, int) + d = self.min_delta * 0.1 if self.precision else self.min_delta + self.value(self.value() + val * d) + if not encoder: + asyncio.create_task(self.btnhan(button, val, d)) # Handle increase and decrease buttons. Redefined by textbox.py, scale_log.py - async def btnhan(self, button, up): - if self.precision: - d = self.min_delta * 0.1 - maxd = self.max_delta - else: - d = self.min_delta - maxd = d * 4 # Why move fast in precision mode? - self.value(self.value() + up * d) + async def btnhan(self, button, up, d): + maxd = self.max_delta if self.precision else d * 4 # Why move fast in precision mode? t = ticks_ms() while not button(): await asyncio.sleep_ms(0) # Quit fast on button release diff --git a/gui/demos/active.py b/gui/demos/active.py index 66818c3..cd5282b 100644 --- a/gui/demos/active.py +++ b/gui/demos/active.py @@ -47,13 +47,11 @@ class BaseScreen(Screen): self.vslider = Slider(wri, 2, 2, callback=self.slider_cb, bdcolor=RED, slotcolor=BLUE, legends=('0.0', '0.5', '1.0'), value=0.5) - #Label(wri, 2, self.vslider.mcol, 'FF') col = 80 row = 15 self.hslider = HorizSlider(wri, row, col, callback=self.slider_cb, bdcolor=GREEN, slotcolor=BLUE, legends=('0.0', '0.5', '1.0'), value=0.7) - Label(wri, self.hslider.mrow, self.hslider.mcol, 'FF') row += 30 self.scale = Scale(wri, row, col, width = 150, tickcb = tickcb, pointercolor=RED, fontcolor=YELLOW, bdcolor=CYAN, diff --git a/gui/demos/various.py b/gui/demos/various.py index 02c6267..4271857 100644 --- a/gui/demos/various.py +++ b/gui/demos/various.py @@ -61,11 +61,8 @@ class FooScreen(Screen): m0 = Meter(wri, 10, 240, divisions = 4, ptcolor=YELLOW, height=80, width=15, label='Meter example', style=Meter.BAR, legends=('0.0', '0.5', '1.0')) - #Label(wri, 2, m0.mcol, 'FF') # Instantiate displayable objects. bgcolor forces complete redraw. dial = Dial(wri, 2, 2, height = 75, ticks = 12, bgcolor=BLACK, bdcolor=None, label=120) # Border in fg color - #Label(wri, dial.mrow, 2, 'FF') - #Label(wri, dial.mrow, dial.mcol, 'FF') scale = Scale(wri, 2, 100, width = 124, tickcb = tickcb, pointercolor=RED, fontcolor=YELLOW, bdcolor=CYAN) diff --git a/gui/primitives/encoder.py b/gui/primitives/encoder.py new file mode 100644 index 0000000..8c206be --- /dev/null +++ b/gui/primitives/encoder.py @@ -0,0 +1,72 @@ +# encoder.py Asynchronous driver for incremental quadrature encoder. + +# Copyright (c) 2021 Peter Hinch +# Released under the MIT License (MIT) - see LICENSE file + +# This driver is intended for encoder-based control knobs. It is +# unsuitable for NC machine applications. Please see the docs. + +import uasyncio as asyncio +from machine import Pin + +class Encoder: + delay = 100 # Pause (ms) for motion to stop + + def __init__(self, pin_x, pin_y, v=0, vmin=None, vmax=None, div=1, + callback=lambda a, b : None, args=()): + self._pin_x = pin_x + self._pin_y = pin_y + self._v = 0 # Hardware value always starts at 0 + self._cv = v # Current (divided) value + if ((vmin is not None) and v < min) or ((vmax is not None) and v > vmax): + raise ValueError('Incompatible args: must have vmin <= v <= vmax') + self._tsf = asyncio.ThreadSafeFlag() + trig = Pin.IRQ_RISING | Pin.IRQ_FALLING + try: + xirq = pin_x.irq(trigger=trig, handler=self._x_cb, hard=True) + yirq = pin_y.irq(trigger=trig, handler=self._y_cb, hard=True) + except TypeError: # hard arg is unsupported on some hosts + xirq = pin_x.irq(trigger=trig, handler=self._x_cb) + yirq = pin_y.irq(trigger=trig, handler=self._y_cb) + asyncio.create_task(self._run(vmin, vmax, div, callback, args)) + + # Hardware IRQ's + def _x_cb(self, pin): + fwd = pin() ^ self._pin_y() + self._v += 1 if fwd else -1 + self._tsf.set() + + def _y_cb(self, pin): + fwd = pin() ^ self._pin_x() ^ 1 + self._v += 1 if fwd else -1 + self._tsf.set() + + async def _run(self, vmin, vmax, div, cb, args): + pv = self._v # Prior hardware value + cv = self._cv # Current divided value as passed to callback + pcv = cv # Prior divided value passed to callback + mod = 0 + delay = self.delay + while True: + await self._tsf.wait() + await asyncio.sleep_ms(delay) # Wait for motion to stop + new = self._v # Sample hardware (atomic read) + a = new - pv # Hardware change + # Ensure symmetrical bahaviour for + and - values + q, r = divmod(abs(a), div) + if a < 0: + r = -r + q = -q + pv = new - r # Hardware value when local value was updated + cv += q + if vmax is not None: + cv = min(cv, vmax) + if vmin is not None: + cv = max(cv, vmin) + self._cv = cv # For value() + if cv != pcv: + cb(cv, cv - pcv, *args) # User CB in uasyncio context + pcv = cv + + def value(self): + return self._cv diff --git a/gui/widgets/listbox.py b/gui/widgets/listbox.py index 8b07ce9..8198b31 100644 --- a/gui/widgets/listbox.py +++ b/gui/widgets/listbox.py @@ -78,18 +78,17 @@ class Listbox(Widget): self.value(v) return v - def do_up(self, _): - if v := self._value: - self.value(v - 1) + def do_adj(self, _, val): + v = self._value + if val > 0: + if v: + self.value(v - 1) + elif val < 0: + if v < len(self.elements) - 1: + self.value(v + 1) if (self.also & Listbox.ON_MOVE): # Treat as if select pressed self.do_sel() - def do_down(self, _): - if (v := self._value) < len(self.elements) - 1: - self.value(v + 1) - if (self.also & Listbox.ON_MOVE): - self.do_sel() - # Callback runs if select is pressed. Also (if ON_LEAVE) if user changes # list currency and then moves off the control. Otherwise if we have a # callback that refreshes another control, that second control does not diff --git a/gui/widgets/scale_log.py b/gui/widgets/scale_log.py index d804988..bb8437c 100644 --- a/gui/widgets/scale_log.py +++ b/gui/widgets/scale_log.py @@ -23,6 +23,7 @@ dolittle = lambda *_ : None # Start value is 1.0. User applies scaling to value and ticks callback. class ScaleLog(LinearIO): + encoder_rate = 5 def __init__(self, writer, row, col, *, decades=5, height=0, width=160, bdcolor=None, fgcolor=None, bgcolor=None, @@ -141,6 +142,14 @@ class ScaleLog(LinearIO): return self._value + # Adjust widget's value. Args: button pressed, amount of increment + def do_adj(self, button, val): + if isinstance(button, int): # Using an encoder + delta = self.delta * self.encoder_rate * 0.1 if self.precision else self.delta * self.encoder_rate + self.value(self.value() * (1 + delta)**val) + else: + asyncio.create_task(self.btnhan(button, val, d)) + async def btnhan(self, button, up): up = up == 1 if self.precision: diff --git a/hardware_setup.py b/hardware_setup.py index c031844..5f9e15c 100644 --- a/hardware_setup.py +++ b/hardware_setup.py @@ -1,122 +1,54 @@ -# color_setup.py Customise for your hardware config +# ili9341_pico.py Customise for your hardware config # Released under the MIT License (MIT). See LICENSE. -# Copyright (c) 2021 Peter Hinch, Ihor Nehrutsa +# Copyright (c) 2021 Peter Hinch -# Supports: -# TTGO T-Display 1.14" 135*240(Pixel) based on ST7789V -# http://www.lilygo.cn/claprod_view.aspx?TypeId=62&Id=1274 -# http://www.lilygo.cn/prod_view.aspx?TypeId=50044&Id=1126 -# https://github.com/Xinyuan-LilyGO/TTGO-T-Display -# https://github.com/Xinyuan-LilyGO/TTGO-T-Display/blob/master/image/pinmap.jpg -# https://github.com/Xinyuan-LilyGO/TTGO-T-Display/blob/master/schematic/ESP32-TFT(6-26).pdf - -# WIRING (TTGO T-Display pin numbers and names). -# Pinout of TFT Driver -# ST7789 ESP32 -# TFT_MISO N/A -TFT_MOSI = 19 # (SDA on schematic pdf) SPI interface output/input pin. -TFT_SCLK = 18 # This pin is used to be serial interface clock. -TFT_CS = 5 # Chip selection pin, low enable, high disable. -TFT_DC = 16 # Display data/command selection pin in 4-line serial interface. -TFT_RST = 23 # This signal will reset the device,Signal is active low. -TFT_BL = 4 # (LEDK on schematic pdf) Display backlight control pin - -ADC_IN = 34 # Measuring battery or USB voltage, see comment below -ADC_EN = 14 # (PWR_EN on schematic pdf) is the ADC detection enable port - -BUTTON1 = 35 # right of the USB connector -BUTTON2 = 0 # left of the USB connector - -# ESP32 pins, free for use in user applications -#I2C_SDA = 21 # hardware ID 0 -#I2C_SCL = 22 - -#UART2TXD = 17 - -#GPIO2 = 2 -#GPIO15 = 15 -#GPIO13 = 13 -#GPIO12 = 12 - -#GPIO37 = 37 -#GPIO38 = 38 -#UART1TXD = 4 -#UART1RXD = 5 -#GPIO18 = 18 -#GPIO19 = 19 -#GPIO17 = 17 - -#DAC1 = 25 -#DAC2 = 26 - -# Input only pins -#GPIO36 = 36 # input only -#GPIO39 = 39 # input only +# As written, supports: +# ili9341 240x320 displays on Pi Pico +# Edit the driver import for other displays. # Demo of initialisation procedure designed to minimise risk of memory fail # when instantiating the frame buffer. The aim is to do this as early as # possible before importing other modules. -from machine import Pin, SPI, ADC, freq +# WIRING +# Pico Display +# GPIO Pin +# 3v3 36 Vin +# IO6 9 CLK Hardware SPI0 +# IO7 10 DATA (AKA SI MOSI) +# IO8 11 DC +# IO9 12 Rst +# Gnd 13 Gnd +# IO10 14 CS + +# Pushbuttons are wired between the pin and Gnd +# Pico pin Meaning +# 16 Operate current control +# 17 Decrease value of current control +# 18 Select previous control +# 19 Select next control +# 20 Increase value of current control + +from machine import Pin, SPI, freq import gc -from drivers.st7789.st7789_4bit import * -SSD = ST7789 - -pdc = Pin(TFT_DC, Pin.OUT, value=0) # Arbitrary pins -pcs = Pin(TFT_CS, Pin.OUT, value=1) -prst = Pin(TFT_RST, Pin.OUT, value=1) -pbl = Pin(TFT_BL, Pin.OUT, value=1) - +from drivers.ili93xx.ili9341 import ILI9341 as SSD +freq(250_000_000) # RP2 overclock +# Create and export an SSD instance +pdc = Pin(8, Pin.OUT, value=0) # Arbitrary pins +prst = Pin(9, Pin.OUT, value=1) +pcs = Pin(10, Pin.OUT, value=1) +spi = SPI(0, baudrate=30_000_000) gc.collect() # Precaution before instantiating framebuf -# Conservative low baudrate. Can go to 62.5MHz. -spi = SPI(1, 30_000_000, sck=Pin(TFT_SCLK), mosi=Pin(TFT_MOSI)) -freq(160_000_000) - -''' TTGO - v +----------------+ - 40 | | | - ^ | +------+ | pin 36 - | | | | | - | | | | | -240 | | | | | - | | | | | - | | | | | - v | +------+ | - 40 | | | Reset button - ^ +----------------+ - >----<------>----< - 52 135 xx - BUTTON2 BUTTON1 -''' -# Right way up landscape: defined as top left adjacent to pin 36 -ssd = SSD(spi, height=135, width=240, dc=pdc, cs=pcs, rst=prst, disp_mode=LANDSCAPE, display=TDISPLAY) -# Normal portrait display: consistent with TTGO logo at top -# ssd = SSD(spi, height=240, width=135, dc=pdc, cs=pcs, rst=prst, disp_mode=PORTRAIT, display=TDISPLAY) +ssd = SSD(spi, pcs, pdc, prst, usd=True) from gui.core.ugui import Display # Create and export a Display instance # Define control buttons -nxt = Pin(32, Pin.IN, Pin.PULL_UP) # Move to next control -sel = Pin(36, Pin.IN, Pin.PULL_UP) # Operate current control -prev = Pin(38, Pin.IN, Pin.PULL_UP) # Move to previous control -increase = Pin(37, Pin.IN, Pin.PULL_UP) # Increase control's value -decrease = Pin(39, Pin.IN, Pin.PULL_UP) # Decrease control's value -display = Display(ssd, nxt, sel, prev, increase, decrease) - -# optional -# b1 = Pin(BUTTON1, Pin.IN) -# b2 = Pin(BUTTON2, Pin.IN) -# adc_en = Pin(ADC_EN, Pin.OUT, value=1) -# adc_in = ADC(Pin(ADC_IN)) -# adc_en.value(0) -''' -Set ADC_EN to "1" and read voltage in BAT_ADC, -if this voltage more than 4.3 V device have powered from USB. -If less then 4.3 V - device have power from battery. -To save battery you can set ADC_EN to "0" and in this case the USB converter -will be power off and do not use your battery. -When you need to measure battery voltage first set ADC_EN to "1", -measure voltage and then set ADC_EN back to "0" for save battery. -''' +nxt = Pin(19, Pin.IN, Pin.PULL_UP) # Move to next control +sel = Pin(16, Pin.IN, Pin.PULL_UP) # Operate current control +prev = Pin(18, Pin.IN, Pin.PULL_UP) # Move to previous control +increase = Pin(20, Pin.IN, Pin.PULL_UP) # Increase control's value +decrease = Pin(17, Pin.IN, Pin.PULL_UP) # Decrease control's value +display = Display(ssd, nxt, sel, prev, increase, decrease, 5) # Encoder diff --git a/setup_examples/st7789_ttgo.py b/setup_examples/st7789_ttgo.py index c031844..8da5ad7 100644 --- a/setup_examples/st7789_ttgo.py +++ b/setup_examples/st7789_ttgo.py @@ -101,9 +101,14 @@ from gui.core.ugui import Display nxt = Pin(32, Pin.IN, Pin.PULL_UP) # Move to next control sel = Pin(36, Pin.IN, Pin.PULL_UP) # Operate current control prev = Pin(38, Pin.IN, Pin.PULL_UP) # Move to previous control -increase = Pin(37, Pin.IN, Pin.PULL_UP) # Increase control's value -decrease = Pin(39, Pin.IN, Pin.PULL_UP) # Decrease control's value -display = Display(ssd, nxt, sel, prev, increase, decrease) +encoder = 5 # Divide by 5 +if encoder: + increase = Pin(25, Pin.IN, Pin.PULL_UP) # Encoder x and y pins + decrease = Pin(33, Pin.IN, Pin.PULL_UP) +else: + increase = Pin(37, Pin.IN, Pin.PULL_UP) # Increase control's value + decrease = Pin(39, Pin.IN, Pin.PULL_UP) # Decrease control's value +display = Display(ssd, nxt, sel, prev, increase, decrease, encoder) # optional # b1 = Pin(BUTTON1, Pin.IN)