Skip to content

Commit ffb26ad

Browse files
committed
Fix most type hinting issues in library/lcd
1 parent 37280a5 commit ffb26ad

File tree

7 files changed

+164
-115
lines changed

7 files changed

+164
-115
lines changed

library/lcd/color.py

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
from typing import Union, Tuple
2+
3+
from PIL import ImageColor
4+
5+
RGBColor = Tuple[int, int, int]
6+
7+
# Color can be an RGB tuple (RGBColor), or a string in any of these formats:
8+
# - "r, g, b" (e.g. "255, 0, 0"), as is found in the themes' yaml settings
9+
# - any of the formats supported by PIL: https://pillow.readthedocs.io/en/stable/reference/ImageColor.html
10+
#
11+
# For example, here are multiple ways to write the pure red color:
12+
# - (255, 0, 0)
13+
# - "255, 0, 0"
14+
# - "#ff0000"
15+
# - "red"
16+
# - "hsl(0, 100%, 50%)"
17+
Color = Union[str, RGBColor]
18+
19+
def parse_color(color: Color) -> RGBColor:
20+
# even if undocumented, let's be nice and accept a list in lieu of a tuple
21+
if isinstance(color, tuple) or isinstance(color, list):
22+
if len(color) != 3:
23+
raise ValueError("RGB color must have 3 values")
24+
return (int(color[0]), int(color[1]), int(color[2]))
25+
26+
if not isinstance(color, str):
27+
raise ValueError("Color must be either an RGB tuple or a string")
28+
29+
# Try to parse it as our custom "r, g, b" format
30+
rgb = color.split(',')
31+
if len(rgb) == 3:
32+
r, g, b = rgb
33+
try:
34+
rgbcolor = (int(r.strip()), int(g.strip()), int(b.strip()))
35+
except ValueError:
36+
# at least one element can't be converted to int, we continue to
37+
# try parsing as a PIL color
38+
pass
39+
else:
40+
return rgbcolor
41+
42+
# fallback as a PIL color
43+
rgbcolor = ImageColor.getrgb(color)
44+
if len(rgbcolor) == 4:
45+
return (rgbcolor[0], rgbcolor[1], rgbcolor[2])
46+
return rgbcolor
47+

library/lcd/lcd_comm.py

Lines changed: 80 additions & 80 deletions
Original file line numberDiff line numberDiff line change
@@ -25,12 +25,13 @@
2525
import time
2626
from abc import ABC, abstractmethod
2727
from enum import IntEnum
28-
from typing import Tuple, List
28+
from typing import Tuple, List, Optional, Dict
2929

3030
import serial
3131
from PIL import Image, ImageDraw, ImageFont
3232

3333
from library.log import logger
34+
from library.lcd.color import Color, parse_color
3435

3536

3637
class Orientation(IntEnum):
@@ -42,7 +43,7 @@ class Orientation(IntEnum):
4243

4344
class LcdComm(ABC):
4445
def __init__(self, com_port: str = "AUTO", display_width: int = 320, display_height: int = 480,
45-
update_queue: queue.Queue = None):
46+
update_queue: Optional[queue.Queue] = None):
4647
self.lcd_serial = None
4748

4849
# String containing absolute path to serial port e.g. "COM3", "/dev/ttyACM1" or "AUTO" for auto-discovery
@@ -67,7 +68,10 @@ def __init__(self, com_port: str = "AUTO", display_width: int = 320, display_hei
6768
self.image_cache = {} # { key=path, value=PIL.Image }
6869

6970
# Create a cache to store opened fonts, to avoid opening and loading from the filesystem every time
70-
self.font_cache = {} # { key=(font, size), value=PIL.ImageFont }
71+
self.font_cache: Dict[
72+
Tuple[str, int], # key=(font, size)
73+
ImageFont.FreeTypeFont # value= a loaded freetype font
74+
] = {}
7175

7276
def get_width(self) -> int:
7377
if self.orientation == Orientation.PORTRAIT or self.orientation == Orientation.REVERSE_PORTRAIT:
@@ -97,7 +101,7 @@ def openSerial(self):
97101
logger.debug(f"Static COM port: {self.com_port}")
98102

99103
try:
100-
self.lcd_serial = serial.Serial(self.com_port, 115200, timeout=1, rtscts=1)
104+
self.lcd_serial = serial.Serial(self.com_port, 115200, timeout=1, rtscts=True)
101105
except Exception as e:
102106
logger.error(f"Cannot open COM port {self.com_port}: {e}")
103107
try:
@@ -106,10 +110,20 @@ def openSerial(self):
106110
os._exit(0)
107111

108112
def closeSerial(self):
109-
try:
113+
if self.lcd_serial is not None:
110114
self.lcd_serial.close()
111-
except:
112-
pass
115+
116+
def serial_write(self, data: bytes):
117+
assert self.lcd_serial is not None
118+
self.lcd_serial.write(data)
119+
120+
def serial_read(self, size: int) -> bytes:
121+
assert self.lcd_serial is not None
122+
return self.lcd_serial.read(size)
123+
124+
def serial_flush_input(self):
125+
if self.lcd_serial is not None:
126+
self.lcd_serial.reset_input_buffer()
113127

114128
def WriteData(self, byteBuffer: bytearray):
115129
self.WriteLine(bytes(byteBuffer))
@@ -124,39 +138,39 @@ def SendLine(self, line: bytes):
124138

125139
def WriteLine(self, line: bytes):
126140
try:
127-
self.lcd_serial.write(line)
128-
except serial.serialutil.SerialTimeoutException:
141+
self.serial_write(line)
142+
except serial.SerialTimeoutException:
129143
# We timed-out trying to write to our device, slow things down.
130144
logger.warning("(Write line) Too fast! Slow down!")
131-
except serial.serialutil.SerialException:
145+
except serial.SerialException:
132146
# Error writing data to device: close and reopen serial port, try to write again
133147
logger.error(
134148
"SerialException: Failed to send serial data to device. Closing and reopening COM port before retrying once.")
135149
self.closeSerial()
136150
time.sleep(1)
137151
self.openSerial()
138-
self.lcd_serial.write(line)
152+
self.serial_write(line)
139153

140154
def ReadData(self, readSize: int):
141155
try:
142-
response = self.lcd_serial.read(readSize)
156+
response = self.serial_read(readSize)
143157
# logger.debug("Received: [{}]".format(str(response, 'utf-8')))
144158
return response
145-
except serial.serialutil.SerialTimeoutException:
159+
except serial.SerialTimeoutException:
146160
# We timed-out trying to read from our device, slow things down.
147161
logger.warning("(Read data) Too fast! Slow down!")
148-
except serial.serialutil.SerialException:
162+
except serial.SerialException:
149163
# Error writing data to device: close and reopen serial port, try to read again
150164
logger.error(
151165
"SerialException: Failed to read serial data from device. Closing and reopening COM port before retrying once.")
152166
self.closeSerial()
153167
time.sleep(1)
154168
self.openSerial()
155-
return self.lcd_serial.read(readSize)
169+
return self.serial_read(readSize)
156170

157171
@staticmethod
158172
@abstractmethod
159-
def auto_detect_com_port():
173+
def auto_detect_com_port() -> Optional[str]:
160174
pass
161175

162176
@abstractmethod
@@ -193,7 +207,7 @@ def SetOrientation(self, orientation: Orientation):
193207
@abstractmethod
194208
def DisplayPILImage(
195209
self,
196-
image: Image,
210+
image: Image.Image,
197211
x: int = 0, y: int = 0,
198212
image_width: int = 0,
199213
image_height: int = 0
@@ -213,20 +227,17 @@ def DisplayText(
213227
height: int = 0,
214228
font: str = "roboto-mono/RobotoMono-Regular.ttf",
215229
font_size: int = 20,
216-
font_color: Tuple[int, int, int] = (0, 0, 0),
217-
background_color: Tuple[int, int, int] = (255, 255, 255),
218-
background_image: str = None,
230+
font_color: Color = (0, 0, 0),
231+
background_color: Color = (255, 255, 255),
232+
background_image: Optional[str] = None,
219233
align: str = 'left',
220-
anchor: str = None,
234+
anchor: str = 'la',
221235
):
222236
# Convert text to bitmap using PIL and display it
223237
# Provide the background image path to display text with transparent background
224238

225-
if isinstance(font_color, str):
226-
font_color = tuple(map(int, font_color.split(', ')))
227-
228-
if isinstance(background_color, str):
229-
background_color = tuple(map(int, background_color.split(', ')))
239+
font_color = parse_color(font_color)
240+
background_color = parse_color(background_color)
230241

231242
assert x <= self.get_width(), 'Text X coordinate ' + str(x) + ' must be <= display width ' + str(
232243
self.get_width())
@@ -247,13 +258,11 @@ def DisplayText(
247258
text_image = self.open_image(background_image)
248259

249260
# Get text bounding box
250-
if (font, font_size) not in self.font_cache:
251-
self.font_cache[(font, font_size)] = ImageFont.truetype("./res/fonts/" + font, font_size)
252-
font = self.font_cache[(font, font_size)]
261+
fontdata = self.open_font(font, font_size)
253262
d = ImageDraw.Draw(text_image)
254263

255264
if width == 0 or height == 0:
256-
left, top, right, bottom = d.textbbox((x, y), text, font=font, align=align, anchor=anchor)
265+
left, top, right, bottom = d.textbbox((x, y), text, font=fontdata, align=align, anchor=anchor)
257266

258267
# textbbox may return float values, which is not good for the bitmap operations below.
259268
# Let's extend the bounding box to the next whole pixel in all directions
@@ -263,21 +272,21 @@ def DisplayText(
263272
left, top, right, bottom = x, y, x + width, y + height
264273

265274
if anchor.startswith("m"):
266-
x = (right + left) / 2
275+
x = int((right + left) / 2)
267276
elif anchor.startswith("r"):
268277
x = right
269278
else:
270279
x = left
271280

272281
if anchor.endswith("m"):
273-
y = (bottom + top) / 2
282+
y = int((bottom + top) / 2)
274283
elif anchor.endswith("b"):
275284
y = bottom
276285
else:
277286
y = top
278287

279288
# Draw text onto the background image with specified color & font
280-
d.text((x, y), text, font=font, fill=font_color, align=align, anchor=anchor)
289+
d.text((x, y), text, font=fontdata, fill=font_color, align=align, anchor=anchor)
281290

282291
# Restrict the dimensions if they overflow the display size
283292
left = max(left, 0)
@@ -292,18 +301,15 @@ def DisplayText(
292301

293302
def DisplayProgressBar(self, x: int, y: int, width: int, height: int, min_value: int = 0, max_value: int = 100,
294303
value: int = 50,
295-
bar_color: Tuple[int, int, int] = (0, 0, 0),
304+
bar_color: Color = (0, 0, 0),
296305
bar_outline: bool = True,
297-
background_color: Tuple[int, int, int] = (255, 255, 255),
298-
background_image: str = None):
306+
background_color: Color = (255, 255, 255),
307+
background_image: Optional[str] = None):
299308
# Generate a progress bar and display it
300309
# Provide the background image path to display progress bar with transparent background
301310

302-
if isinstance(bar_color, str):
303-
bar_color = tuple(map(int, bar_color.split(', ')))
304-
305-
if isinstance(background_color, str):
306-
background_color = tuple(map(int, background_color.split(', ')))
311+
bar_color = parse_color(bar_color)
312+
background_color = parse_color(background_color)
307313

308314
assert x <= self.get_width(), 'Progress bar X coordinate must be <= display width'
309315
assert y <= self.get_height(), 'Progress bar Y coordinate must be <= display height'
@@ -343,26 +349,21 @@ def DisplayProgressBar(self, x: int, y: int, width: int, height: int, min_value:
343349

344350
def DisplayLineGraph(self, x: int, y: int, width: int, height: int,
345351
values: List[float],
346-
min_value: int = 0,
347-
max_value: int = 100,
352+
min_value: float = 0,
353+
max_value: float = 100,
348354
autoscale: bool = False,
349-
line_color: Tuple[int, int, int] = (0, 0, 0),
355+
line_color: Color = (0, 0, 0),
350356
line_width: int = 2,
351357
graph_axis: bool = True,
352-
axis_color: Tuple[int, int, int] = (0, 0, 0),
353-
background_color: Tuple[int, int, int] = (255, 255, 255),
354-
background_image: str = None):
358+
axis_color: Color = (0, 0, 0),
359+
background_color: Color = (255, 255, 255),
360+
background_image: Optional[str] = None):
355361
# Generate a plot graph and display it
356362
# Provide the background image path to display plot graph with transparent background
357363

358-
if isinstance(line_color, str):
359-
line_color = tuple(map(int, line_color.split(', ')))
360-
361-
if isinstance(axis_color, str):
362-
axis_color = tuple(map(int, axis_color.split(', ')))
363-
364-
if isinstance(background_color, str):
365-
background_color = tuple(map(int, background_color.split(', ')))
364+
line_color = parse_color(line_color)
365+
axis_color = parse_color(axis_color)
366+
background_color = parse_color(background_color)
366367

367368
assert x <= self.get_width(), 'Progress bar X coordinate must be <= display width'
368369
assert y <= self.get_height(), 'Progress bar Y coordinate must be <= display height'
@@ -428,47 +429,41 @@ def DisplayLineGraph(self, x: int, y: int, width: int, height: int,
428429
# Draw Legend
429430
draw.line([0, 0, 1, 0], fill=axis_color)
430431
text = f"{int(max_value)}"
431-
font = ImageFont.truetype("./res/fonts/" + "roboto/Roboto-Black.ttf", 10)
432-
left, top, right, bottom = font.getbbox(text)
432+
fontdata = self.open_font("roboto/Roboto-Black.ttf", 10)
433+
_, top, right, bottom = fontdata.getbbox(text)
433434
draw.text((2, 0 - top), text,
434-
font=font, fill=axis_color)
435+
font=fontdata, fill=axis_color)
435436

436437
text = f"{int(min_value)}"
437-
font = ImageFont.truetype("./res/fonts/" + "roboto/Roboto-Black.ttf", 10)
438-
left, top, right, bottom = font.getbbox(text)
438+
_, top, right, bottom = fontdata.getbbox(text)
439439
draw.text((width - 1 - right, height - 2 - bottom), text,
440-
font=font, fill=axis_color)
440+
font=fontdata, fill=axis_color)
441441

442442
self.DisplayPILImage(graph_image, x, y)
443443

444444
def DisplayRadialProgressBar(self, xc: int, yc: int, radius: int, bar_width: int,
445445
min_value: int = 0,
446446
max_value: int = 100,
447-
angle_start: int = 0,
448-
angle_end: int = 360,
447+
angle_start: float = 0,
448+
angle_end: float = 360,
449449
angle_sep: int = 5,
450450
angle_steps: int = 10,
451451
clockwise: bool = True,
452452
value: int = 50,
453-
text: str = None,
453+
text: Optional[str] = None,
454454
with_text: bool = True,
455455
font: str = "roboto/Roboto-Black.ttf",
456456
font_size: int = 20,
457-
font_color: Tuple[int, int, int] = (0, 0, 0),
458-
bar_color: Tuple[int, int, int] = (0, 0, 0),
459-
background_color: Tuple[int, int, int] = (255, 255, 255),
460-
background_image: str = None):
457+
font_color: Color = (0, 0, 0),
458+
bar_color: Color = (0, 0, 0),
459+
background_color: Color = (255, 255, 255),
460+
background_image: Optional[str] = None):
461461
# Generate a radial progress bar and display it
462462
# Provide the background image path to display progress bar with transparent background
463463

464-
if isinstance(bar_color, str):
465-
bar_color = tuple(map(int, bar_color.split(', ')))
466-
467-
if isinstance(background_color, str):
468-
background_color = tuple(map(int, background_color.split(', ')))
469-
470-
if isinstance(font_color, str):
471-
font_color = tuple(map(int, font_color.split(', ')))
464+
bar_color = parse_color(bar_color)
465+
background_color = parse_color(background_color)
466+
font_color = parse_color(font_color)
472467

473468
if angle_start % 361 == angle_end % 361:
474469
if clockwise:
@@ -586,17 +581,22 @@ def DisplayRadialProgressBar(self, xc: int, yc: int, radius: int, bar_width: int
586581
if with_text:
587582
if text is None:
588583
text = f"{int(pct * 100 + .5)}%"
589-
font = ImageFont.truetype("./res/fonts/" + font, font_size)
590-
left, top, right, bottom = font.getbbox(text)
584+
fontdata = self.open_font(font, font_size)
585+
left, top, right, bottom = fontdata.getbbox(text)
591586
w, h = right - left, bottom - top
592587
draw.text((radius - w / 2, radius - top - h / 2), text,
593-
font=font, fill=font_color)
588+
font=fontdata, fill=font_color)
594589

595590
self.DisplayPILImage(bar_image, xc - radius, yc - radius)
596591

597592
# Load image from the filesystem, or get from the cache if it has already been loaded previously
598-
def open_image(self, bitmap_path: str) -> Image:
593+
def open_image(self, bitmap_path: str) -> Image.Image:
599594
if bitmap_path not in self.image_cache:
600595
logger.debug("Bitmap " + bitmap_path + " is now loaded in the cache")
601596
self.image_cache[bitmap_path] = Image.open(bitmap_path)
602597
return copy.copy(self.image_cache[bitmap_path])
598+
599+
def open_font(self, name: str, size: int) -> ImageFont.FreeTypeFont:
600+
if (name, size) not in self.font_cache:
601+
self.font_cache[(name, size)] = ImageFont.truetype("./res/fonts/" + name, size)
602+
return self.font_cache[(name, size)]

0 commit comments

Comments
 (0)