-
-
Notifications
You must be signed in to change notification settings - Fork 1
/
Copy pathlcd.py
439 lines (352 loc) · 13.7 KB
/
lcd.py
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
"""
Abstraction of the character LCD.
"""
from adafruit_character_lcd.character_lcd import Character_LCD
from adafruit_character_lcd.character_lcd_i2c import Character_LCD_I2C
from util import I2CDeviceAutoSelector
from nvram import NVRAMValues
from sparkfun_serlcd import Sparkfun_SerLCD, Sparkfun_SerLCD_I2C
# noinspection PyBroadException
try:
from typing import List
from abc import abstractmethod, ABC
except:
class ABC:
"""
Placeholder for CircuitPython.
"""
pass
# noinspection PyUnusedLocal
def abstractmethod(*args, **kwargs):
"""
Placeholder for CircuitPython.
:param args: Ignored
:param kwargs: Ignored
"""
pass
from busio import I2C
# noinspection PyBroadException
try:
from typing import Optional
except:
# don't care
pass
import adafruit_rgbled
import board
import os
class BacklightColor:
"""
Defines a backlight color (RGB).
"""
def __init__(self, color: tuple[int, int, int]):
"""
:param color: Color as an RGB tuple with each channel 0..255
"""
self.color = color
@staticmethod
def from_setting(name: str, default_value: tuple[int, int, int]):
"""
Gets an instance of a backlight color from its value defined as an int, like 0xFF0000 for red, in settings.toml
with the given name, or if one isn't defined, creates one from the given color tuple.
:param name: Value in settings.toml that might contain a backlight color expressed as an int
:param default_value: Color to use if settings.toml doesn't contain that value
:return: BackgroundColor instance with the appropriate color
"""
value = os.getenv(name)
color = BacklightColor.int_to_tuple(int(value)) if value else default_value
return BacklightColor(color)
@staticmethod
def int_to_tuple(color: int) -> tuple[int, int, int]:
"""
Converts a color expressed as an int, like 0xFF0000 for red, to an RGB color tuple like (255, 0, 0).
:param color: color expressed as an int
:return: color expressed as an RGB tuple
"""
# adapted from https://github.com/todbot/circuitpython-tricks?tab=readme-ov-file#convert-rgb-tuples-to-int-and-back-again
# noinspection PyTypeChecker
return tuple(color.to_bytes(3, "big"))
def invert(self) -> tuple[int, int, int]:
"""
Inverts the given color; for each channel, 255 - value. Some backlights have a common anode instead of cathode
and therefore the color must be inverted before it gets applied.
:return: Inverted color tuple
"""
(r, g, b) = self.color
return 255 - r, 255 - g, 255 - b
def __str__(self):
"""
Convenience method for printing this color in strings.
:return: This color like "(255, 0, 0)" for red
"""
r, g, b = self.color
return f"({r}, {g}, {b})"
def __eq__(self, other):
return self.color == other.color
class BacklightColors:
"""
Enum-like class of potential backlight colors auto-populated from their settings or defaults if no settings are
defined.
* DEFAULT: normal state
* DIM: DEFAULT, but dimmer due to inactivity
* ERROR: something went wrong (uncaught exception, etc.)
* SUCCESS: something went right (successful POST to API, etc.)
* OFF: off entirely; this one can't be defined in a setting and is always (0, 0, 0)
"""
DEFAULT = BacklightColor.from_setting("BACKLIGHT_COLOR_FULL", (255, 255, 255))
DIM = BacklightColor.from_setting("BACKLIGHT_COLOR_DIM", (128, 128, 128))
ERROR = BacklightColor.from_setting("BACKLIGHT_COLOR_ERROR", (255, 0, 0))
SUCCESS = BacklightColor.from_setting("BACKLIGHT_COLOR_SUCCESS", (0, 255, 0))
OFF = BacklightColor((0, 0, 0))
class Backlight(ABC):
"""
Abstract class to control the LCD's backlight.
"""
def __init__(self):
self.color: Optional[BacklightColor] = None
def set_color(self, color: BacklightColor, only_if_current_color_is: Optional[BacklightColor] = None) -> None:
"""
Sets the color of the backlight. Subclasses must implement set_color_impl() that does the hardware calls.
:param color: Color to set
:param only_if_current_color_is: Only do the color change if the current backlight color is this
"""
if self.color is None or only_if_current_color_is is None or self.color == only_if_current_color_is:
self.set_color_impl(color)
self.color = color
@abstractmethod
def set_color_impl(self, color: BacklightColor):
"""
Set the color of the backlight in hardware. In this abstract class, throws NotImplementedError()
:param color: Color to set
"""
raise NotImplementedError()
def off(self) -> None:
"""
Turns off the backlight.
"""
self.set_color(BacklightColors.OFF)
class AdafruitCharacterLCDBackpackBacklight(Backlight):
"""
Implementation of Backlight that controls an RGB LED and for which the color must be inverted. Adafruit RGB-backlit
LCDs like https://www.adafruit.com/product/498 act like this. The backlight is assumed to be wired as:
* Red: D9
* Green: D5
* Blue: D6
"""
def __init__(self):
super().__init__()
self.device: Optional[adafruit_rgbled.RGBLED] = None
def set_color_impl(self, color: BacklightColor) -> None:
if self.device is None:
self.device = adafruit_rgbled.RGBLED(board.D9, board.D5, board.D6)
self.device.color = color.invert()
class SparkfunSerLCDBacklight(Backlight):
"""
Implementation of Backlight that controls the backlight on a Sparkfun SerLCD:
https://www.sparkfun.com/products/16398
"""
def __init__(self, lcd: Sparkfun_SerLCD):
"""
:param lcd: Instance of Sparkfun_SerLCD hardware
"""
super().__init__()
self.device = lcd
def set_color_impl(self, color: BacklightColor):
r, g, b = color.color
self.device.set_fast_backlight_rgb(r, g, b)
class LCD:
"""
Abstraction of a 20x4 character LCD with backlight.
This class defines some special characters. Use this as literal bytes in strings for them to show up on the LCD;
for example, write RIGHT + "Test" for a right arrow and the string "Test."
* UP_DOWN: an up/down error to hint that up and down can be pressed to increase or decrease a value
* CHECKED: a checkmark
* UNCHECKED: an unchecked checkbox
* CHARGING: device is charging (not used right now)
* RIGHT: right arrow
* LEFT: left arrow
* CENTER: center button hint
* BLOCK: a solid block used in progress bars
Don't construct these; use LCD.get_instance() to auto-detect one.
"""
UP_DOWN = 0
CHECKED = 1
UNCHECKED = 2
CHARGING = 3
RIGHT = 4
LEFT = 5
CENTER = 6
BLOCK = 7
COLUMNS = 20
LINES = 4
def __init__(self, backlight: Backlight):
"""
:param backlight: Backlight instance to control this LCD's backlight color
"""
self.backlight = backlight
for key, value in LCD.CHARS.items():
self.create_special_char(key, value)
def write(self, message: str, coords: tuple[int, int] = (0, 0)) -> None:
"""
Writes a message to the LCD at the given coordinates. The actual writing is delegated to write_impl() in
subclasses. Text that exceeds the width of the display minus the starting X coordinate might wrap on some LCDs
or be truncated on others, but generally the behavior is undefined and you should avoid writing long strings
this way. Same deal with newline characters and especially characters outside the lower ASCII character set.
:param message: Text to write
:param coords: Coordinates (x, y) to write at. (0, 0) is top-left.
"""
LCD.validate_coords(coords)
self.write_impl(message, coords)
@abstractmethod
def write_impl(self, message: str, coords: tuple[int, int]) -> None:
"""
To be implemented by subclasses to write to the LCD hardware. Abstract implementation raises
NotImplementedError.
:param message: Text to write
:param coords: Coordinates (x, y) to write at. (0, 0) is top-left.
"""
raise NotImplementedError()
def write_centered(self, text: str, erase_if_shorter_than: int = None, y_delta: int = 0) -> None:
"""
Writes a message horizontally and vertically centered in the LCD.
:param text: Text to write
:param erase_if_shorter_than: If the given text has fewer than this many characters, erase this many characters
(write a space to them)
:param y_delta: Adjust the y position by this amount, like 1 to move down by one line from centered
"""
if erase_if_shorter_than is not None and len(text) < erase_if_shorter_than:
self.write(" " * erase_if_shorter_than, LCD.get_centered_coords(erase_if_shorter_than))
coords = self.get_centered_coords(len(text), y_delta)
self.write(text, coords)
@staticmethod
def get_centered_coords(char_count: int, y_delta: int = 0):
"""
Given a string of a certain number of characters long and optionally offset vertically a certain amount, get
the coordinates on the LCD where to render that string.
:param char_count: Number of characters long for the string (<= 20)
:param y_delta: Move up (a negative number) or down (a positive number) by this many lines
:return: (x, y) on the screen where to render the string
"""
coords = (max(int(LCD.COLUMNS / 2 - char_count / 2), 0), max(int(LCD.LINES / 2) - 1 + y_delta, 0))
return coords
def write_right_aligned(self, text: str, y: int = 0) -> None:
"""
Writes a message right-aligned.
:param text: Text to write
:param y: y coordinate to write at
"""
if len(text) >= LCD.COLUMNS:
raise ValueError(f"Text exceeds {LCD.COLUMNS} chars: {text}")
self.write(text, (LCD.COLUMNS - len(text), y))
def write_bottom_right_aligned(self, text: str, y_delta: int = 0) -> None:
"""
Writes a message at the bottom-right of the LCD.
:param text: Message to write
:param y_delta: move the message this many lines up from the bottom of the LCD
"""
self.write_right_aligned(text, LCD.LINES - 1 - y_delta)
def write_bottom_left_aligned(self, text: str, y_delta: int = 0) -> None:
"""
Writes a message at the bottom-left of the LCD.
:param text: Message to write
:param y_delta: move the message this many lines up from the bottom of the LCD
"""
self.write(text, (0, LCD.LINES - 1 - y_delta))
@abstractmethod
def clear(self) -> None:
"""
Clears the display. Abstract method and must be overridden by child classes.
"""
raise NotImplementedError()
def __getitem__(self, special_char: int) -> str:
"""
Convenience method to get a character instance of the given special character, like LCD.RIGHT.
:param special_char: LCD special character, like LCD.RIGHT.
:return: A character instance that can be concatenated to or embedded in a string
"""
return chr(special_char)
@abstractmethod
def create_special_char(self, special_char: int, data: List[int]) -> None:
"""
Initializes a special character in the LCD device to a given bitmap (of sorts). Abstract method and must be
implemented by child classes.
:param special_char: Special character, like LCD.RIGHT.
:param data: Pixel data (see https://www.quinapalus.com/hd44780udg.html and LCD.CHARS)
"""
raise NotImplementedError()
@staticmethod
def validate_coords(coords: tuple[int, int]) -> None:
"""
Validates that the given (x, y) are within the bounds of the LCD and if not raises ValueError.
:param coords: (x, y) tuple of coordinates; (0, 0) is top-left
"""
x, y = coords
if x < 0 or x >= LCD.COLUMNS:
raise ValueError(f"x ({x}) must be >= 0 and < {LCD.COLUMNS}")
if y < 0 or y >= LCD.LINES:
raise ValueError(f"y ({y}) must be >= 0 and < {LCD.LINES}")
@staticmethod
def get_instance(i2c: I2C):
"""
Gets a concrete instance of LCD given what's found on the I2C bus. If one isn't immediately found, then
repeated scan attempts are made with a brief delay but then eventually gives up and raises a RuntimeError.
:param i2c: I2C bus that has an LCD on it
:return: Concrete LCD instance
"""
return I2CDeviceAutoSelector(i2c).get_device(
address_map = {
0x20: lambda _: AdafruitCharacterLCDBackpack(Character_LCD_I2C(i2c, LCD.COLUMNS, LCD.LINES)),
0x72: lambda _: SparkfunSerLCD(Sparkfun_SerLCD_I2C(i2c))
}
)
# https://www.quinapalus.com/hd44780udg.html
# LCD uses 5x8 pixel chars and supports up to 8 custom chars
LCD.CHARS = {
LCD.UP_DOWN: [0x4, 0xe, 0x1f, 0x0, 0x0, 0x1f, 0xe, 0x4],
LCD.UNCHECKED: [0x0, 0x1f, 0x11, 0x11, 0x11, 0x1f, 0x0, 0x0],
LCD.CHECKED: [0x0, 0x1, 0x3, 0x16, 0x1c, 0x8, 0x0, 0x0],
LCD.CHARGING: [0x4, 0xe, 0x1b, 0x0, 0xe, 0xa, 0xe, 0xe],
LCD.RIGHT: [0x10, 0x18, 0x1c, 0x1e, 0x1c, 0x18, 0x10, 0x0],
LCD.LEFT: [0x2, 0x6, 0xe, 0x1e, 0xe, 0x6, 0x2, 0x0],
LCD.CENTER: [0x0, 0xe, 0x11, 0x15, 0x11, 0xe, 0x0, 0x0],
LCD.BLOCK: [0x1f, 0x1f, 0x1f, 0x1f, 0x1f, 0x1f, 0x1f, 0x1f]
}
class SparkfunSerLCD(LCD):
"""
An implementation of LCD for a Sparkfun SerLCD (https://www.sparkfun.com/products/16398).
"""
def __init__(self, lcd: Sparkfun_SerLCD):
"""
:param lcd: Sparkfun_SerLCD hardware instance
"""
self.device = lcd
super().__init__(SparkfunSerLCDBacklight(lcd))
if not NVRAMValues.HAS_CONFIGURED_SPARKFUN_LCD:
self.device.command(0x2F) # turn off command messages
self.device.command(0x31) # disable splash screen
NVRAMValues.HAS_CONFIGURED_SPARKFUN_LCD.write(True)
def write_impl(self, message: str, coords: tuple[int, int]) -> None:
x, y = coords
self.device.set_cursor(x, y)
self.device.write(message)
def clear(self) -> None:
self.device.clear()
def create_special_char(self, special_char: int, data: List[int]) -> None:
self.device.create_character(special_char, data)
class AdafruitCharacterLCDBackpack(LCD):
"""
An implementation of LCD that uses the Adafruit LCD backpack (https://www.adafruit.com/product/292).
"""
def __init__(self, lcd: Character_LCD):
"""
:param lcd: Character_LCD hardware instance
"""
self.device = lcd
super().__init__(AdafruitCharacterLCDBackpackBacklight())
def create_special_char(self, special_char: int, data: List[int]) -> None:
self.device.create_char(special_char, data)
def write_impl(self, message: str, coords: tuple[int, int]):
x, y = coords
self.device.cursor_position(x, y)
self.device.message = message
def clear(self) -> None:
self.device.clear()