firmware/shared/lcd_display.py
2023-12-11 12:25:25 -05:00

724 lines
23 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, utime, array
from uasyncio import sleep_ms
from graphics_q1 import Graphics
from st7788 import ST7788
from utils import xfp2str
from ucollections import namedtuple
# the one font: fixed-width (except for a few double-width chars)
from font_iosevka import CELL_W, CELL_H, TEXT_PALETTES, COL_TEXT, COL_DARK_TEXT
from font_iosevka import FontIosevka
#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
COL_PROGRESS = COL_TEXT
# Attribute stored per-char; really just an index into TEXT_PALETTES
FLAG_INVERT = 0x10000
FLAG_DARK = 0x20000
ATTR_MASK = 0x30000
# use this to describe cursor you need.
# - outline leaves most of the cell unaffected (just 1px inside border)
# - solid/outline available in double-width as well
CURSOR_SOLID = 0x01
CURSOR_OUTLINE = 0x02
CURSOR_MENU = 0x03
CURSOR_DW_OUTLINE = 0x11
CURSOR_DW_SOLID = 0x12
CursorSpec = namedtuple('CursorSpec', 'x y cur_type')
CURSOR_DW_Mask = 0x10
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 rgb(r,g,b):
# as if 24-bit, but we're 16
r = int(r/255 * 0x1f)
g = int(g/255 * 0x3f)
b = int(b/255 * 0x1f)
return (r<<11) | (g << 5) | b
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 glob import settings
if settings:
rv['xfp'] = settings.get('xfp')
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()
from gpu import GPUAccess
self.gpu = GPUAccess()
try:
self.gpu.upgrade_if_needed()
except:
print("GPU upgrade failed")
self.last_buf = self.make_buf(0)
self.next_buf = self.make_buf(32)
# state of progress bar (bottom edge)
self.last_prog_x = -1
self.next_prog_x = 0
# state of scroll bar (right side)
self.last_scroll = 0.0
self.next_scroll = None
self.last_bar_update = 0
#self.dis.fill_screen() # defer a bit
self.draw_status(full=True)
def make_buf(self, ch=32):
# make a screen-state storage buffer. One spot per character, but needs to
# store attributes as well as support 16-bit unicode
return [array.array('I', (ch for i in range(CHARS_W))) for y in range(CHARS_H)]
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 set_lcd_brightness(self, on_battery=None, tmp_override=None):
# Call when battery changes state, or if you want max for a bit (QR display)
# - call w/o args to get back to state we're supposed to be in.
from glob import settings
from q1 import get_batt_threshold, DEFAULT_BATT_BRIGHTNESS
if tmp_override is not None:
self.dis.backlight.intensity(tmp_override)
return
# otherwise: respect setting
if on_battery is None:
on_battery = (get_batt_threshold() != None)
if on_battery:
# user-defined brightness when running on batteries.
lvl = DEFAULT_BATT_BRIGHTNESS
if settings:
lvl = settings.get('bright', DEFAULT_BATT_BRIGHTNESS)
self.dis.backlight.intensity(lvl)
else:
# full brightness when on VBUS and when showing QR's
self.dis.backlight.intensity(255)
def draw_status(self, full=False, **kws):
self.gpu.take_spi()
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'])
self.set_lcd_brightness(True)
if 'plugged' in kws:
self.image(b_x, 0, 'plugged')
self.set_lcd_brightness(False)
if 'bip39' in kws:
self.image(102, 0, 'bip39_%d' % kws['bip39'])
if 'tmp' in kws:
self.image(165, 0, 'tmp_%d' % kws['tmp'])
xfp = kws.get('xfp', None) # expects an integer
if xfp != None:
x = 215
for ch in xfp2str(xfp).lower():
self.image(x, 0, 'ch_'+ch)
x += 6
x = 265
if 'edge' in kws:
self.image(x, 0, 'edge')
elif 'devmode' in kws:
self.image(x+5, 0, 'devmode')
x = 8
for dx, meta in [(7, 'shift'), (37, 'symbol'), (58, 'caps')]:
if meta in kws:
self.image(x+dx, 0, '%s_%d' % (meta, kws[meta]))
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.gpu.take_spi()
self.dis.show_zpixels(x, y, w, h, data)
self.mark_correct(x, y, w, h)
def mark_correct(self, px, py, w, h):
# mark a subset of the screen as already drawn correctly
# - because we drew an image in that spot already (immediate)
# - hard: need to convert from pixel coord space to chars
if py < TOP_MARGIN:
# status icons not a concern
return
cy = (py - TOP_MARGIN) // CELL_H
cx = (px - LEFT_MARGIN) // CELL_W
cw = (w+CELL_W) // CELL_W
ch = (h+CELL_H) // CELL_H
#print('pixel %dx%d @ (%d,%d) => %dx%d @ (%d,%d)' % (w, h, px,py, cw, ch, cx,cy))
for y in range(cy, cy+ch+1):
for x in range(cx, cx+cw+1):
try:
self.last_buf[y][x] = self.next_buf[y][x] = 0xfffe
except IndexError:
pass
self.show()
def icon(self, x, y, name, invert=0):
# plan is these are chars or images
raise NotImplementedError
def width(self, msg):
# length of text msg in char cells
# - typically 1:1 but we have a few double-width chars
rv = len(msg)
rv += sum(1 for ch in msg if ch in FontIosevka.DOUBLE_WIDE)
return rv
def text(self, x,y, msg, font=None, invert=False, dark=False):
# Draw at x,y (in cell positions, not pixels)
# - use invert=1 to get reverse video
# - returns ending X position, where you might want a cursor after
end_x = None
# encode text attribute for this part
attr = 0
if invert:
attr = FLAG_INVERT
if dark:
attr = FLAG_DARK
if x is None or x < 0:
w = self.width(msg)
if x == None:
# center: also blanks rest of line
x = max(0, (CHARS_W - w) // 2)
end_x = x + w
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)
end_x = x + w
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
for ch in msg:
if x >= CHARS_W: break
self.next_buf[y][x] = ord(ch) + attr
x += 1
if ch in FontIosevka.DOUBLE_WIDE:
self.next_buf[y][x] = 0
x += 1
return end_x if end_x is not None else x
def real_clear(self, _internal=False):
# fill to black, but only text area, not status bar
if not _internal:
self.gpu.take_spi()
self.dis.fill_rect(0, TOP_MARGIN, WIDTH, HEIGHT-TOP_MARGIN, 0x0)
self.last_buf = self.make_buf(32)
self.next_buf = self.make_buf(32)
self.next_prog_x = 0
self.next_scroll = None
def clear(self):
# clear text
self.next_buf = self.make_buf(32)
# clear progress bar / scroll
self.next_prog_x = 0
self.next_scroll = None
def show(self, just_lines=None, cursor=None, max_bright=False):
# Push internal screen representation to device, effeciently
self.gpu.take_spi()
lines = just_lines or range(CHARS_H)
for y in lines:
x = 0
while x < CHARS_W:
if self.next_buf[y][x] == self.last_buf[y][x]:
# already correct
x += 1
continue
py = TOP_MARGIN + (y * CELL_H)
px = LEFT_MARGIN + (x * CELL_W)
ch = chr(self.next_buf[y][x] & ~ATTR_MASK)
attr = (self.next_buf[y][x] & ATTR_MASK)
if ch == ' ':
# space - look for horz runs & fill w/ blank
run = 1
for x2 in range(x+1, CHARS_W):
if self.next_buf[y][x] != self.next_buf[y][x2]:
break
run += 1
self.dis.fill_rect(px, py, run*CELL_W, CELL_H,
COL_TEXT if attr == FLAG_INVERT else 0)
x += run
continue
fn = FontIosevka.lookup(ch)
if not fn:
# unknown char
x += 1
continue
self.dis.show_pal_pixels(px, py, fn.w, fn.h, TEXT_PALETTES[attr >> 16], fn.bits)
x += fn.w // CELL_W
self.last_buf[y][:] = self.next_buf[y]
# maybe update progress bar
if self.next_prog_x != self.last_prog_x:
# NOTE: misc/gpu/lcd.c must be updated to match any changes here
x = self.next_prog_x
if x:
self.dis.fill_rect(0, HEIGHT-3, x, 3, COL_PROGRESS)
if x != WIDTH:
self.dis.fill_rect(x, HEIGHT-3, WIDTH-x, 3, COL_BLACK)
self.last_prog_x = x
if self.next_scroll != self.last_scroll:
self._draw_scroll_bar(self.next_scroll)
self.last_scroll = self.next_scroll
if cursor:
# implement CursorSpec values
assert 0 <= cursor.x < CHARS_W, 'cur x'
assert 0 <= cursor.y < CHARS_H, 'cur y'
self.gpu.cursor_at(cursor.x, cursor.y, cursor.cur_type)
self.last_buf[cursor.y][cursor.x] = 0xfffd
if (cursor.cur_type & CURSOR_DW_Mask) and (cursor.x < CHARS_W-1):
self.last_buf[cursor.y][cursor.x+1] = 0xfffd
# modulate the LCD brightness if we're showing QR or something
if max_bright:
self.set_lcd_brightness(tmp_override=255)
self._max_bright = True
elif hasattr(self, '_max_bright'):
self.set_lcd_brightness() # back to normal
del self._max_bright
# When drawing another screen for a bit, then coming back, use these
def save_state(self):
# TODO: should be a dataclass w/ all our state details
return ([array.array('I', ln) for ln in self.last_buf], self.last_prog_x, self.last_scroll)
def restore_state(self, old_state):
rows, self.next_prog_x, self.next_scroll = old_state
for y in range(CHARS_H):
self.next_buf[y][:] = rows[y]
self.show()
# obsolete OLED approach
def save(self):
raise NotImplementedError
def restore(self):
raise NotImplementedError
def hline(self, y):
# used only in hsm_ux.py
#self.dis.fill_rect(0,y, WIDTH, 1, 0xffff)
pass
def vline(self, x):
# used only in hsm_ux.py
#self.dis.fill_rect(x,TOP_MARGIN, 1, ACTIVE_H, 0xffff)
pass
def clear_rect(self, x,y, w,h):
# but see clear_box() instead
raise NotImplementedError
def scroll_bar(self, fraction):
# next show(), we will show scroll bar on right edge
self.next_scroll = fraction
def _draw_scroll_bar(self, fraction):
# Immediately draw bar along right edge.
# - length means nothing, just vert position
bw = 2 # bar width, height
bh = ACTIVE_H // 4
if fraction is None:
self.dis.fill_rect(WIDTH-bw, TOP_MARGIN, bw, ACTIVE_H, COL_BLACK)
return
self.dis.fill_rect(WIDTH-bw, TOP_MARGIN, bw, ACTIVE_H, COL_DARK_TEXT)
pos = int((ACTIVE_H-bh)*fraction)
if pos+bh > ACTIVE_H:
pos = ACTIVE_H - bh
self.dis.fill_rect(WIDTH-bw, TOP_MARGIN+pos, bw, bh, COL_TEXT)
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)
self.show()
def splash(self):
# display a splash screen with some version numbers
self.real_clear()
y = 6
self.image(None, 90, 'splash')
self.text(None, y, "Don't Trust. Verify.")
from version import get_mpy_version
timestamp, label, *_ = get_mpy_version()
self.text(0, -1, 'Version '+label)
self.text(-1, -1, timestamp)
self.show([y, CHARS_H-1])
def splash_text(self, msg):
# additional progress during splash/startup screen
# - not sure any of this occurs during normal login sequence
y = 1
self.text(None, y, msg)
self.show([y])
def progress_bar(self, percent):
# Horizontal progress bar
# takes 0.0 .. 1.0 as fraction of doneness
percent = max(0, min(1.0, percent))
self.next_prog_x = int(WIDTH * percent)
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)
self.show()
def mark_sensitive(self, from_y, to_y):
# XXX maybe TODO ? or remove ... LCD doesnt have issue
return
def busy_bar(self, enable, speed_code=5):
# activate the GPU to render/animate this.
# - show() in this funct is relied-upon by callers
if enable:
self.last_prog_x = self.next_prog_x = -1
self.show()
self.gpu.busy_bar(True)
else:
# - self.show will stop animation
# - and redraw w/ no bar visible
self.last_prog_x = -1
self.next_prog_x = 0
self.show()
def set_brightness(self, val):
# - was only used by HSM ux code
# - QR code display brightness could be done in show_qr_data()
# - see self.set_lcd_brightness()
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 the mk4 had a 5th, half-line as a hint
return
if msg[0] == ' ' and space_indicators:
# unused, but might need?
msg = '' + msg[1:]
x = 0
self.text(x, ry, ' '+msg+' ', invert=is_sel)
if is_checked:
self.text(len(msg)+2, ry, '')
def menu_show(self, cursor_y):
cs = CursorSpec(0, cursor_y or 0, CURSOR_MENU)
self.show(cursor=cs)
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
self.show()
def draw_story(self, lines, top, num_lines, is_sensitive):
self.clear()
y=0
for ln in lines:
if ln == 'EOT':
self.text(0, y, ''*CHARS_W, dark=True)
continue
elif ln and ln[0] == '\x01':
# ux_show_story: title ... but we have no special font? Inverse!
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
# maybe show something other than QR contents under it
msg = sidebar or msg
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 expand onto LCD
# - 8-bit aligned rows of data
scan_w, _, data = qr_data.packed()
self.gpu.take_spi()
self.real_clear()
self.dis.show_qr_data(x, TOP_MARGIN + y, w, expand, scan_w, data)
self.mark_correct(x, TOP_MARGIN + y, qw, qw)
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)
# pass a "max_brightness" param here, which would be cleared after next show
self.show(max_bright=True)
def draw_bbqr_progress(self, hdr, got_parts, corrupt=False):
# we've seen at least one BBQr QR, so update display w/ progress bar
# - lots of data so we can show nice animation
# - hdr:BBQrHeader instance
count = len(got_parts)
if hdr.num_parts < (CHARS_W // 2):
# if not too many parts, show - or 3 as they arrive
pat = []
for i in range(hdr.num_parts):
if i in got_parts:
pat.append(str(i+1))
else:
wl = 1 if i < 9 else 2
if corrupt and i == hdr.which:
pat.append('X'*wl)
else:
pat.append('-'*wl)
pat = (' ' if hdr.num_parts <= 8 else ' ').join(pat)
if len(pat) > CHARS_W:
pat = ''
else:
pat = '' # clear line
self.text(None, -3, pat)
self.text(None, -2, 'Keep scanning more...' if count < hdr.num_parts else 'Got all parts!')
self.text(None, -1, '%s: %d of %d parts' % (hdr.file_label(), count, hdr.num_parts),
dark=True)
percent = count / hdr.num_parts
self.progress_bar(percent)
self.show()
def draw_box(self, x, y, w, h, **kw):
# using line-drawing chars, draw a box
# returns X pos of first inside char
assert 0 <= h <= CHARS_H-2 # 8 max
assert 0 <= w <= CHARS_W-2 # 32 max
if x is None:
x = (CHARS_W - w - 2) // 2
ln = '' + (''*w) + ''
self.text(x, y, ln, **kw)
for yy in range(y+1, y+h+1):
self.text(x, yy, '', **kw)
self.text(x+w+1, yy, '', **kw)
ln = '' + ln[1:-1] + ''
self.text(x, y+h+1, ln, **kw)
return x+1
def clear_box(self, x, y, w, h):
# clear (w/ spaces) a box on screen
for Y in range(y, y+h):
for X in range(x, x+w):
assert 0 <= X < CHARS_W
assert 0 <= Y < CHARS_H
self.next_buf[Y][X] = 32
async def bootrom_takeover(self):
# we are going to go into the bootrom and have it do stuff on the
# screen... we need to redraw completely on return
self.gpu.take_spi()
self.last_buf = self.make_buf(0)
self.last_prog_x = -1
await sleep_ms(20) # at least one frame (60Hz) so GPU stops
# here for mpy reasons
WIDTH = const(320)
HEIGHT = const(240)
# EOF