firmware/shared/lcd_display.py
2023-12-05 12:33:29 +01:00

408 lines
12 KiB
Python

# (c) Copyright 2023 by Coinkite Inc. This file is covered by license found in COPYING-CC.
#
# lcd_display.py - LCD rendering for Q1's 320x240 pixel *colour* display!
#
import machine, uzlib, ckcc, utime, struct, array, sys
import framebuf
import uasyncio
from uasyncio import sleep_ms
from graphics_q1 import Graphics
from graphics import Graphics as obsoleteGraphics
import sram2
from st7788 import ST7788
# the one font: fixed-width (except for a few double-width chars)
from font_iosevka import CELL_W, CELL_H, TEXT_PALETTE, TEXT_PALETTE_INV
from font_iosevka import FontIosevka
# free unused screen buffers, we don't work that way
del sram2.display_buf
del sram2.display2_buf
# one byte per pixel; fixed palette maps to BGR565 in C code
#display2_buf = bytearray(320 * 240)
#WIDTH = const(320)
#HEIGHT = const(240)
LEFT_MARGIN = const(7)
TOP_MARGIN = const(15)
ACTIVE_H = const(240 - TOP_MARGIN)
CHARS_W = const(34)
CHARS_H = const(10)
# colouuurs: RGB565
COL_WHITE = 0xffff
COL_BLACK = 0x0000
def grey_level(amt):
# give percent 0..1.0
r = int(amt * 0x1f)
g = int(amt * 0x3f)
#b = int(amt * 0x1f) # same as Red
return (r<<11) | (g << 5) | r
def get_sys_status():
# read current values for all status-bar items
# - normally we update as we go along.
# - return a dict
from q1 import get_batt_threshold
rv = dict(shift=0, caps=0, symbol=0)
b = get_batt_threshold()
if b is None:
rv['plugged'] = True
else:
rv['bat'] = b
from stash import bip39_passphrase
rv['bip39'] = int(bool(bip39_passphrase))
from pincodes import pa
rv['tmp'] = int(bool(pa.tmp_value))
from version import is_edge, is_devmode
if is_edge:
rv['edge'] = 1
elif is_devmode:
rv['devmode'] = 1
return rv
class Display:
# XXX move to global, but rest of system looks at these member vars
WIDTH = 320
HEIGHT = 240
# use these negative X values for auto layout features
CENTER = -2
RJUST = -1
# use this to know if on Q1 or earlier
has_lcd = True
# icon names and their values (0 / 1)
status_icons = {}
def __init__(self):
self.dis = ST7788()
self.last_bar_update = 0
self.dis.fill_screen()
self.draw_status(full=True)
def redraw_metakeys(self, new_state):
# called when metakeys have changed state
self.draw_status(**new_state)
async def async_draw_status(self, **kws):
self.draw_status(**kws)
def draw_status(self, full=False, **kws):
if full:
y = TOP_MARGIN
self.dis.fill_rect(0, 0, WIDTH, y-1, 0x0)
self.dis.fill_rect(0, y-1, WIDTH, 1, grey_level(0.25))
kws = get_sys_status()
b_x = 290
if 'bat' in kws:
self.image(b_x, 0, 'bat_%d' % kws['bat'])
if 'plugged' in kws:
self.image(b_x, 0, 'plugged')
if 'bip39' in kws:
self.image(120, 0, 'bip39_%d' % kws['bip39'])
if 'tmp' in kws:
self.image(200, 0, 'tmp_%d' % kws['tmp'])
if 'edge' in kws:
self.image(260, 0, 'edge')
elif 'devmode' in kws:
self.image(260, 0, 'devmode')
for x, meta in [(7, 'shift'), (38, 'symbol'), (65, 'caps')]:
if meta in kws:
self.image(x, 0, '%s_%d' % (meta, kws[meta]))
def width(self, msg, font):
return sum(font.lookup(ord(ch)).w for ch in msg)
def image(self, x, y, name):
# display a graphics image, immediately
w,h, data = getattr(Graphics, name)
if x == None:
x = max(0, (WIDTH - w) // 2)
self.dis.show_zpixels(x, y, w, h, data)
def icon(self, x, y, name, invert=0):
# XXX plan is these are chars or images
return 10, 10
def text(self, x,y, msg, font=None, invert=0):
# Draw at x,y (in cell positions, not pixels)
# Use invert=1 to get reverse video
if x is None or x < 0:
w = len(msg)
if x == None:
# center: also blanks rest of line
x = max(0, (CHARS_W - w) // 2)
msg = ((' '*x) + msg + (' ' * CHARS_W))[0:CHARS_W]
x = 0
else:
# measure from right edge (right justify)
x = max(0, CHARS_W - w + 1 + x)
if y < 0:
# measure up from bottom edge
y = CHARS_H + y
if y >= CHARS_H:
print("BAD Draw '%s' at y=%d" % (msg, y))
return # past bottom
# convert to pixels
x = LEFT_MARGIN + (x * CELL_W)
y = TOP_MARGIN + (y * CELL_H)
for ch in msg:
fn = FontIosevka.lookup(ch)
if fn is None:
# draw blanks for unknowns
x += CELL_W
continue
self.dis.show_pal_pixels(x, y, fn.w, fn.h,
TEXT_PALETTE if not invert else TEXT_PALETTE_INV, fn.bits)
x += fn.w
if x >= WIDTH: break
def clear(self):
# fill to black, but only text area, not status bar
self.dis.fill_rect(0, TOP_MARGIN, WIDTH, HEIGHT-TOP_MARGIN, 0x0)
def clear_rect(self, x,y, w,h):
self.dis.fill_rect(x,y, w,h, 0x0000)
def show(self):
# used to be critical, pushing internal screen representation to device
# however, we can be more selective/auto now...
# - but might still use... idk
pass
# rather than clearing and redrawing, use this buffer w/ fixed parts of screen
# - obsolete concept
def save(self):
pass
def restore(self):
pass
def hline(self, y):
self.dis.fill_rect(0,y, WIDTH, 1, 0xffff)
def vline(self, x):
self.dis.fill_rect(x,TOP_MARGIN, 1, ACTIVE_H, 0xffff)
def scroll_bar(self, fraction):
# along right edge
self.dis.fill_rect(WIDTH-5, 0, 5, HEIGHT, 0)
#self.icon(WIDTH-3, 1, 'scroll'); // dots + arrow
mm = HEIGHT-6
pos = min(int(mm*fraction), mm)
self.dis.fill_rect(WIDTH-2, pos, 1, 16, 1)
def fullscreen(self, msg, percent=None):
# show a simple message "fullscreen".
self.clear()
self.text(None, CHARS_H // 3, msg)
if percent is not None:
self.progress_bar(percent)
def splash(self):
# display a splash screen with some version numbers
self.clear()
self.image(None, 24, 'splash')
from version import get_mpy_version
timestamp, label, *_ = get_mpy_version()
y = HEIGHT-CELL_H-2
self.text(0, -1, 'Version '+label)
self.text(-1, -1, timestamp)
def splash_text(self, msg):
# additional progress during splash/startup screen
self.text(None, 6, msg)
def progress_bar(self, percent):
# Horizontal progress bar
# takes 0.0 .. 1.0 as fraction of doneness
percent = max(0, min(1.0, percent))
x = int(WIDTH * percent)
self.dis.fill_rect(0, HEIGHT-3, x, 3, COL_WHITE)
self.dis.fill_rect(x, HEIGHT-3, WIDTH-x, 3, COL_BLACK)
def progress_sofar(self, done, total):
# Update progress bar, but only if it's been a while since last update
if utime.ticks_diff(utime.ticks_ms(), self.last_bar_update) < 100:
return
self.last_bar_update = utime.ticks_ms()
self.progress_bar_show(done / total)
def progress_bar_show(self, percent):
# useful as a callback
self.progress_bar(percent)
def mark_sensitive(self, from_y, to_y):
return # XXX maybe TODO ? or remove ... LCD doesnt have issue
wx = WIDTH-4 # avoid scroll bar
for y in range(from_y, to_y):
ln = max(2, ckcc.rng() % 32)
self.dis.line(wx-ln, y, wx, y, 1)
def busy_bar(self, enable, speed_code=5):
# TODO: activate the GPU to render/animate this.
print("busy_bar: %s" % enable)
return
def set_brightness(self, val):
# normal = 0x7f, brightness=0xff, dim=0x00 (but they are all very similar)
# XXX maybe control BL_ENABLE timing? or not required.
return
def menu_draw(self, ry, msg, is_sel, is_checked, space_indicators):
# draw a menu item, perhaps selected, checked.
assert CHARS_W == 34
if ry >= CHARS_H:
# higher layer tries to draw partial line past bottom, and that's
# ok because needed on mk4
return
if msg[0] == ' ' and space_indicators:
msg = '_' + msg[1:] # XXX improve me w/ special char
ln = ('' if is_sel else ' ') + ('%-32s' % msg)
if is_checked:
ln = ln[:CHARS_W-3] + ''
self.text(0, ry, ln, invert=is_sel)
def show_yikes(self, lines):
# dump a stack trace
# - intended for photos, sent to support!
from utils import word_wrap
self.clear()
self.text(None, 0, '>>>> Yikes!! <<<<')
y = 1
for num, ln in enumerate(lines):
ln = ln.strip()
if ln[0:6] == 'File "':
# convert: File "main.py", line 63, in interact
# into: main.py:63 interact
ln = ln[6:].replace('", line ', ':').replace(', in ', ' ')
if ln[0] == '/':
ln = ln.split('/')[-1]
for second, l in enumerate(word_wrap(ln, CHARS_W)):
self.text(1 if second else 0, y, l)
y += 1
def draw_story(self, lines, top, num_lines, is_sensitive):
self.clear()
y=0
for ln in lines:
if ln == 'EOT':
self.hline( TOP_MARGIN + (y*CELL_H) )
continue
elif ln and ln[0] == '\x01':
# title ... but we have no special font?
self.text(0, y, ln[1:], invert=1)
else:
self.text(0, y, ln)
y += 1
if is_sensitive and len(ln) > 3 and ln[2] == ':':
self.mark_sensitive(y, y+13)
self.scroll_bar(top / num_lines)
self.show()
def draw_qr_display(self, qr_data, msg, is_alnum, sidebar, idx_hint, invert):
# Show a QR code on screen w/ some text under it
# - invert not supported on Q1
# - sidebar not supported here (see users.py)
# - we need one more (white) pixel on all sides
from utils import word_wrap
assert not sidebar
if msg:
if len(msg) <= CHARS_W:
parts = [msg]
elif ' ' not in msg and (len(msg) <= CHARS_W*2):
# fits in two lines, but has no spaces (ie. payment addr)
# so split nicely, and shift off center
hh = len(msg) // 2
parts = [msg[0:hh] + ' ', ' '+msg[hh:]]
else:
# do word wrap
parts = list(word_wrap(msg, CHARS_W))
num_lines = len(parts)
else:
num_lines = 0
if num_lines > 2:
# show no text if it would be too big (case: 18, 24 seed words)
num_lines = 0
del parts
w = qr_data.width()
# always draw as large as possible (vertical is limit)
expand = max(1, (ACTIVE_H - (num_lines * CELL_H)) // (w+2))
qw = (w+2) * expand
# horz/vert center in available space
y = (ACTIVE_H - (num_lines * CELL_H) - qw) // 2
x = (WIDTH - qw) // 2
# send packed pixel data to C level to decode and exand to LCD
# - 8-bit aligned rows of data
scan_w, _, data = qr_data.packed()
self.clear()
self.dis.show_qr_data(x, TOP_MARGIN + y, w, expand, scan_w, data)
if num_lines:
# centered text under that
y = CHARS_H - num_lines
for line in parts:
self.text(None, y, line)
y += 1
if idx_hint:
# show path index number: just 1 or 2 digits
self.text(-1, 0, idx_hint)
self.busy_bar(False) # includes show
# here for mpy reasons
WIDTH = const(320)
HEIGHT = const(240)
# EOF