495 lines
16 KiB
Python
495 lines
16 KiB
Python
|
#!/usr/bin/python
|
|||
|
#
|
|||
|
# Urwid LCD display module
|
|||
|
# Copyright (C) 2010 Ian Ward
|
|||
|
#
|
|||
|
# This library is free software; you can redistribute it and/or
|
|||
|
# modify it under the terms of the GNU Lesser General Public
|
|||
|
# License as published by the Free Software Foundation; either
|
|||
|
# version 2.1 of the License, or (at your option) any later version.
|
|||
|
#
|
|||
|
# This library is distributed in the hope that it will be useful,
|
|||
|
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|||
|
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
|
|||
|
# Lesser General Public License for more details.
|
|||
|
#
|
|||
|
# You should have received a copy of the GNU Lesser General Public
|
|||
|
# License along with this library; if not, write to the Free Software
|
|||
|
# Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 USA
|
|||
|
#
|
|||
|
# Urwid web site: https://urwid.org/
|
|||
|
|
|||
|
|
|||
|
from __future__ import annotations
|
|||
|
|
|||
|
import time
|
|||
|
import typing
|
|||
|
from collections.abc import Iterable, Sequence
|
|||
|
|
|||
|
from .display_common import BaseScreen
|
|||
|
|
|||
|
if typing.TYPE_CHECKING:
|
|||
|
from typing_extensions import Literal
|
|||
|
|
|||
|
|
|||
|
class LCDScreen(BaseScreen):
|
|||
|
def set_terminal_properties(self, colors=None, bright_is_bold=None,
|
|||
|
has_underline=None):
|
|||
|
pass
|
|||
|
|
|||
|
def set_mouse_tracking(self, enable=True):
|
|||
|
pass
|
|||
|
|
|||
|
def set_input_timeouts(self, *args):
|
|||
|
pass
|
|||
|
|
|||
|
def reset_default_terminal_palette(self, *args):
|
|||
|
pass
|
|||
|
|
|||
|
def draw_screen(self, size, r ):
|
|||
|
pass
|
|||
|
|
|||
|
def clear(self):
|
|||
|
pass
|
|||
|
|
|||
|
def get_cols_rows(self):
|
|||
|
return self.DISPLAY_SIZE
|
|||
|
|
|||
|
|
|||
|
|
|||
|
class CFLCDScreen(LCDScreen):
|
|||
|
"""
|
|||
|
Common methods for Crystal Fontz LCD displays
|
|||
|
"""
|
|||
|
KEYS = [None, # no key with code 0
|
|||
|
'up_press', 'down_press', 'left_press',
|
|||
|
'right_press', 'enter_press', 'exit_press',
|
|||
|
'up_release', 'down_release', 'left_release',
|
|||
|
'right_release', 'enter_release', 'exit_release',
|
|||
|
'ul_press', 'ur_press', 'll_press', 'lr_press',
|
|||
|
'ul_release', 'ur_release', 'll_release', 'lr_release']
|
|||
|
CMD_PING = 0
|
|||
|
CMD_VERSION = 1
|
|||
|
CMD_CLEAR = 6
|
|||
|
CMD_CGRAM = 9
|
|||
|
CMD_CURSOR_POSITION = 11 # data = [col, row]
|
|||
|
CMD_CURSOR_STYLE = 12 # data = [style (0-4)]
|
|||
|
CMD_LCD_CONTRAST = 13 # data = [contrast (0-255)]
|
|||
|
CMD_BACKLIGHT = 14 # data = [power (0-100)]
|
|||
|
CMD_LCD_DATA = 31 # data = [col, row] + text
|
|||
|
CMD_GPO = 34 # data = [pin(0-12), value(0-100)]
|
|||
|
|
|||
|
# sent from device
|
|||
|
CMD_KEY_ACTIVITY = 0x80
|
|||
|
CMD_ACK = 0x40 # in high two bits ie. & 0xc0
|
|||
|
|
|||
|
CURSOR_NONE = 0
|
|||
|
CURSOR_BLINKING_BLOCK = 1
|
|||
|
CURSOR_UNDERSCORE = 2
|
|||
|
CURSOR_BLINKING_BLOCK_UNDERSCORE = 3
|
|||
|
CURSOR_INVERTING_BLINKING_BLOCK = 4
|
|||
|
|
|||
|
MAX_PACKET_DATA_LENGTH = 22
|
|||
|
|
|||
|
colors = 1
|
|||
|
has_underline = False
|
|||
|
|
|||
|
def __init__(self, device_path: str, baud: int):
|
|||
|
"""
|
|||
|
device_path -- eg. '/dev/ttyUSB0'
|
|||
|
baud -- baud rate
|
|||
|
"""
|
|||
|
super().__init__()
|
|||
|
self.device_path = device_path
|
|||
|
from serial import Serial
|
|||
|
self._device = Serial(device_path, baud, timeout=0)
|
|||
|
self._unprocessed = bytearray()
|
|||
|
|
|||
|
@classmethod
|
|||
|
def get_crc(cls, buf: bytearray) -> int:
|
|||
|
# This seed makes the output of this shift based algorithm match
|
|||
|
# the table based algorithm. The center 16 bits of the 32-bit
|
|||
|
# "newCRC" are used for the CRC. The MSB of the lower byte is used
|
|||
|
# to see what bit was shifted out of the center 16 bit CRC
|
|||
|
# accumulator ("carry flag analog");
|
|||
|
new_crc = 0x00F32100
|
|||
|
for byte in buf:
|
|||
|
# Push this byte’s bits through a software
|
|||
|
# implementation of a hardware shift & xor.
|
|||
|
for bit_count in range(8):
|
|||
|
# Shift the CRC accumulator
|
|||
|
new_crc >>= 1
|
|||
|
# The new MSB of the CRC accumulator comes
|
|||
|
# from the LSB of the current data byte.
|
|||
|
if byte & (0x01 << bit_count):
|
|||
|
new_crc |= 0x00800000
|
|||
|
# If the low bit of the current CRC accumulator was set
|
|||
|
# before the shift, then we need to XOR the accumulator
|
|||
|
# with the polynomial (center 16 bits of 0x00840800)
|
|||
|
if new_crc & 0x00000080:
|
|||
|
new_crc ^= 0x00840800
|
|||
|
# All the data has been done. Do 16 more bits of 0 data.
|
|||
|
for bit_count in range(16):
|
|||
|
# Shift the CRC accumulator
|
|||
|
new_crc >>= 1
|
|||
|
# If the low bit of the current CRC accumulator was set
|
|||
|
# before the shift we need to XOR the accumulator with
|
|||
|
# 0x00840800.
|
|||
|
if new_crc & 0x00000080:
|
|||
|
new_crc ^= 0x00840800
|
|||
|
# Return the center 16 bits, making this CRC match the one’s
|
|||
|
# complement that is sent in the packet.
|
|||
|
return ((~new_crc) >> 8) & 0xffff
|
|||
|
|
|||
|
def _send_packet(self, command, data):
|
|||
|
"""
|
|||
|
low-level packet sending.
|
|||
|
Following the protocol requires waiting for ack packet between
|
|||
|
sending each packet to the device.
|
|||
|
"""
|
|||
|
buf = chr(command) + chr(len(data)) + data
|
|||
|
crc = self.get_crc(buf)
|
|||
|
buf = buf + chr(crc & 0xff) + chr(crc >> 8)
|
|||
|
self._device.write(buf)
|
|||
|
|
|||
|
def _read_packet(self) -> tuple[int, bytearray] | None:
|
|||
|
"""
|
|||
|
low-level packet reading.
|
|||
|
returns (command/report code, data) or None
|
|||
|
|
|||
|
This method stored data read and tries to resync when bad data
|
|||
|
is received.
|
|||
|
"""
|
|||
|
# pull in any new data available
|
|||
|
self._unprocessed += self._device.read()
|
|||
|
while True:
|
|||
|
try:
|
|||
|
command, data, unprocessed = self._parse_data(self._unprocessed)
|
|||
|
self._unprocessed = unprocessed
|
|||
|
return command, data
|
|||
|
except self.MoreDataRequired:
|
|||
|
return None
|
|||
|
except self.InvalidPacket:
|
|||
|
# throw out a byte and try to parse again
|
|||
|
self._unprocessed = self._unprocessed[1:]
|
|||
|
|
|||
|
class InvalidPacket(Exception):
|
|||
|
pass
|
|||
|
|
|||
|
class MoreDataRequired(Exception):
|
|||
|
pass
|
|||
|
|
|||
|
@classmethod
|
|||
|
def _parse_data(cls, data: bytearray) -> tuple[int, bytearray, bytearray]:
|
|||
|
"""
|
|||
|
Try to read a packet from the start of data, returning
|
|||
|
(command/report code, packet_data, remaining_data)
|
|||
|
or raising InvalidPacket or MoreDataRequired
|
|||
|
"""
|
|||
|
if len(data) < 2:
|
|||
|
raise cls.MoreDataRequired
|
|||
|
|
|||
|
command: int = data[0]
|
|||
|
packet_len: int = data[1]
|
|||
|
|
|||
|
if packet_len > cls.MAX_PACKET_DATA_LENGTH:
|
|||
|
raise cls.InvalidPacket("length value too large")
|
|||
|
|
|||
|
if len(data) < packet_len + 4:
|
|||
|
raise cls.MoreDataRequired
|
|||
|
|
|||
|
data_end = 2 + packet_len
|
|||
|
crc = cls.get_crc(data[:data_end])
|
|||
|
pcrc = ord(data[data_end: data_end + 1]) + (ord(data[data_end + 1: data_end + 2]) << 8)
|
|||
|
if crc != pcrc:
|
|||
|
raise cls.InvalidPacket("CRC doesn't match")
|
|||
|
return command, data[2: data_end], data[data_end + 2:]
|
|||
|
|
|||
|
|
|||
|
class KeyRepeatSimulator:
|
|||
|
"""
|
|||
|
Provide simulated repeat key events when given press and
|
|||
|
release events.
|
|||
|
|
|||
|
If two or more keys are pressed disable repeating until all
|
|||
|
keys are released.
|
|||
|
"""
|
|||
|
def __init__(self, repeat_delay: float | int, repeat_next: float | int) -> None:
|
|||
|
"""
|
|||
|
repeat_delay -- seconds to wait before starting to repeat keys
|
|||
|
repeat_next -- time between each repeated key
|
|||
|
"""
|
|||
|
self.repeat_delay = repeat_delay
|
|||
|
self.repeat_next = repeat_next
|
|||
|
self.pressed: dict[str, float] = {}
|
|||
|
self.multiple_pressed = False
|
|||
|
|
|||
|
def press(self, key: str) -> None:
|
|||
|
if self.pressed:
|
|||
|
self.multiple_pressed = True
|
|||
|
self.pressed[key] = time.time()
|
|||
|
|
|||
|
def release(self, key: str) -> None:
|
|||
|
if key not in self.pressed:
|
|||
|
return # ignore extra release events
|
|||
|
del self.pressed[key]
|
|||
|
if not self.pressed:
|
|||
|
self.multiple_pressed = False
|
|||
|
|
|||
|
def next_event(self) -> tuple[float, str] | None:
|
|||
|
"""
|
|||
|
Return (remaining, key) where remaining is the number of seconds
|
|||
|
(float) until the key repeat event should be sent, or None if no
|
|||
|
events are pending.
|
|||
|
"""
|
|||
|
if len(self.pressed) != 1 or self.multiple_pressed:
|
|||
|
return None
|
|||
|
for key in self.pressed:
|
|||
|
return max(0., self.pressed[key] + self.repeat_delay - time.time()), key
|
|||
|
return None
|
|||
|
|
|||
|
def sent_event(self) -> None:
|
|||
|
"""
|
|||
|
Cakk this method when you have sent a key repeat event so the
|
|||
|
timer will be reset for the next event
|
|||
|
"""
|
|||
|
if len(self.pressed) != 1:
|
|||
|
return # ignore event that shouldn't have been sent
|
|||
|
for key in self.pressed:
|
|||
|
self.pressed[key] = (time.time() - self.repeat_delay + self.repeat_next)
|
|||
|
return
|
|||
|
|
|||
|
|
|||
|
class CF635Screen(CFLCDScreen):
|
|||
|
"""
|
|||
|
Crystal Fontz 635 display
|
|||
|
|
|||
|
20x4 character display + cursor
|
|||
|
no foreground/background colors or settings supported
|
|||
|
|
|||
|
see CGROM for list of close unicode matches to characters available
|
|||
|
|
|||
|
6 button input
|
|||
|
up, down, left, right, enter (check mark), exit (cross)
|
|||
|
"""
|
|||
|
DISPLAY_SIZE = (20, 4)
|
|||
|
|
|||
|
# ① through ⑧ are programmable CGRAM (chars 0-7, repeated at 8-15)
|
|||
|
# double arrows (⇑⇓) appear as double arrowheads (chars 18, 19)
|
|||
|
# ⑴ resembles a bell
|
|||
|
# ⑵ resembles a filled-in "Y"
|
|||
|
# ⑶ is the letters "Pt" together
|
|||
|
# partial blocks (▇▆▄▃▁) are actually shorter versions of (▉▋▌▍▏)
|
|||
|
# both groups are intended to draw horizontal bars with pixel
|
|||
|
# precision, use ▇*[▆▄▃▁]? for a thin bar or ▉*[▋▌▍▏]? for a thick bar
|
|||
|
CGROM = (
|
|||
|
"①②③④⑤⑥⑦⑧①②③④⑤⑥⑦⑧"
|
|||
|
"►◄⇑⇓«»↖↗↙↘▲▼↲^ˇ█"
|
|||
|
" !\"#¤%&'()*+,-./"
|
|||
|
"0123456789:;<=>?"
|
|||
|
"¡ABCDEFGHIJKLMNO"
|
|||
|
"PQRSTUVWXYZÄÖÑܧ"
|
|||
|
"¿abcdefghijklmno"
|
|||
|
"pqrstuvwxyzäöñüà"
|
|||
|
"⁰¹²³⁴⁵⁶⁷⁸⁹½¼±≥≤μ"
|
|||
|
"♪♫⑴♥♦⑵⌜⌟“”()αɛδ∞"
|
|||
|
"@£$¥èéùìòÇᴾØøʳÅå"
|
|||
|
"⌂¢ΦτλΩπΨΣθΞ♈ÆæßÉ"
|
|||
|
"ΓΛΠϒ_ÈÊêçğŞşİι~◊"
|
|||
|
"▇▆▄▃▁ƒ▉▋▌▍▏⑶◽▪↑→"
|
|||
|
"↓←ÁÍÓÚÝáíóúýÔôŮů"
|
|||
|
r"ČĔŘŠŽčĕřšž[\]{|}")
|
|||
|
|
|||
|
cursor_style = CFLCDScreen.CURSOR_INVERTING_BLINKING_BLOCK
|
|||
|
|
|||
|
def __init__(
|
|||
|
self,
|
|||
|
device_path: str,
|
|||
|
baud: int = 115200,
|
|||
|
repeat_delay: float = 0.5,
|
|||
|
repeat_next: float = 0.125,
|
|||
|
key_map: Iterable[str] = ('up', 'down', 'left', 'right', 'enter', 'esc'),
|
|||
|
):
|
|||
|
"""
|
|||
|
device_path -- eg. '/dev/ttyUSB0'
|
|||
|
baud -- baud rate
|
|||
|
repeat_delay -- seconds to wait before starting to repeat keys
|
|||
|
repeat_next -- time between each repeated key
|
|||
|
key_map -- the keys to send for this device's buttons
|
|||
|
"""
|
|||
|
super().__init__(device_path, baud)
|
|||
|
|
|||
|
self.repeat_delay = repeat_delay
|
|||
|
self.repeat_next = repeat_next
|
|||
|
self.key_repeat = KeyRepeatSimulator(repeat_delay, repeat_next)
|
|||
|
self.key_map = tuple(key_map)
|
|||
|
|
|||
|
self._last_command = None
|
|||
|
self._last_command_time = 0
|
|||
|
self._command_queue = []
|
|||
|
self._screen_buf = None
|
|||
|
self._previous_canvas = None
|
|||
|
self._update_cursor = False
|
|||
|
|
|||
|
def get_input_descriptors(self):
|
|||
|
"""
|
|||
|
return the fd from our serial device so we get called
|
|||
|
on input and responses
|
|||
|
"""
|
|||
|
return [self._device.fd]
|
|||
|
|
|||
|
def get_input_nonblocking(self) -> tuple[None, list[str], list[int]]:
|
|||
|
"""
|
|||
|
Return a (next_input_timeout, keys_pressed, raw_keycodes)
|
|||
|
tuple.
|
|||
|
|
|||
|
The protocol for our device requires waiting for acks between
|
|||
|
each command, so this method responds to those as well as key
|
|||
|
press and release events.
|
|||
|
|
|||
|
Key repeat events are simulated here as the device doesn't send
|
|||
|
any for us.
|
|||
|
|
|||
|
raw_keycodes are the bytes of messages we received, which might
|
|||
|
not seem to have any correspondence to keys_pressed.
|
|||
|
"""
|
|||
|
data_input: list[str] = []
|
|||
|
raw_data_input: list[int] = []
|
|||
|
timeout = None
|
|||
|
|
|||
|
packet = self._read_packet()
|
|||
|
while packet:
|
|||
|
command, data = packet
|
|||
|
|
|||
|
if command == self.CMD_KEY_ACTIVITY and data:
|
|||
|
d0 = data[0]
|
|||
|
if 1 <= d0 <= 12:
|
|||
|
release = d0 > 6
|
|||
|
keycode = d0 - (release * 6) - 1
|
|||
|
key = self.key_map[keycode]
|
|||
|
if release:
|
|||
|
self.key_repeat.release(key)
|
|||
|
else:
|
|||
|
data_input.append(key)
|
|||
|
self.key_repeat.press(key)
|
|||
|
raw_data_input.append(d0)
|
|||
|
|
|||
|
elif command & 0xc0 == 0x40: # "ACK"
|
|||
|
if command & 0x3f == self._last_command:
|
|||
|
self._send_next_command()
|
|||
|
|
|||
|
packet = self._read_packet()
|
|||
|
|
|||
|
next_repeat = self.key_repeat.next_event()
|
|||
|
if next_repeat:
|
|||
|
timeout, key = next_repeat
|
|||
|
if not timeout:
|
|||
|
data_input.append(key)
|
|||
|
self.key_repeat.sent_event()
|
|||
|
timeout = None
|
|||
|
|
|||
|
return timeout, data_input, raw_data_input
|
|||
|
|
|||
|
def _send_next_command(self) -> None:
|
|||
|
"""
|
|||
|
send out the next command in the queue
|
|||
|
"""
|
|||
|
if not self._command_queue:
|
|||
|
self._last_command = None
|
|||
|
return
|
|||
|
command, data = self._command_queue.pop(0)
|
|||
|
self._send_packet(command, data)
|
|||
|
self._last_command = command # record command for ACK
|
|||
|
self._last_command_time = time.time()
|
|||
|
|
|||
|
def queue_command(self, command: int, data: str) -> None:
|
|||
|
self._command_queue.append((command, data))
|
|||
|
# not waiting? send away!
|
|||
|
if self._last_command is None:
|
|||
|
self._send_next_command()
|
|||
|
|
|||
|
def draw_screen(self, size: tuple[int, int], canvas):
|
|||
|
assert size == self.DISPLAY_SIZE
|
|||
|
|
|||
|
if self._screen_buf:
|
|||
|
osb = self._screen_buf
|
|||
|
else:
|
|||
|
osb = []
|
|||
|
sb = []
|
|||
|
|
|||
|
y = 0
|
|||
|
for row in canvas.content():
|
|||
|
text = []
|
|||
|
for a, cs, run in row:
|
|||
|
text.append(run)
|
|||
|
if not osb or osb[y] != text:
|
|||
|
self.queue_command(self.CMD_LCD_DATA, chr(0) + chr(y) + "".join(text))
|
|||
|
sb.append(text)
|
|||
|
y += 1
|
|||
|
|
|||
|
if (self._previous_canvas and
|
|||
|
self._previous_canvas.cursor == canvas.cursor and
|
|||
|
(not self._update_cursor or not canvas.cursor)):
|
|||
|
pass
|
|||
|
elif canvas.cursor is None:
|
|||
|
self.queue_command(self.CMD_CURSOR_STYLE, chr(self.CURSOR_NONE))
|
|||
|
else:
|
|||
|
x, y = canvas.cursor
|
|||
|
self.queue_command(self.CMD_CURSOR_POSITION, chr(x) + chr(y))
|
|||
|
self.queue_command(self.CMD_CURSOR_STYLE, chr(self.cursor_style))
|
|||
|
|
|||
|
self._update_cursor = False
|
|||
|
self._screen_buf = sb
|
|||
|
self._previous_canvas = canvas
|
|||
|
|
|||
|
def program_cgram(self, index: int, data: Sequence[int]) -> None:
|
|||
|
"""
|
|||
|
Program character data. Characters available as chr(0) through
|
|||
|
chr(7), and repeated as chr(8) through chr(15).
|
|||
|
|
|||
|
index -- 0 to 7 index of character to program
|
|||
|
|
|||
|
data -- list of 8, 6-bit integer values top to bottom with MSB
|
|||
|
on the left side of the character.
|
|||
|
"""
|
|||
|
assert 0 <= index <= 7
|
|||
|
assert len(data) == 8
|
|||
|
self.queue_command(self.CMD_CGRAM, chr(index) + "".join([chr(x) for x in data]))
|
|||
|
|
|||
|
def set_cursor_style(self, style: Literal[1, 2, 3, 4]) -> None:
|
|||
|
"""
|
|||
|
style -- CURSOR_BLINKING_BLOCK, CURSOR_UNDERSCORE,
|
|||
|
CURSOR_BLINKING_BLOCK_UNDERSCORE or
|
|||
|
CURSOR_INVERTING_BLINKING_BLOCK
|
|||
|
"""
|
|||
|
assert 1 <= style <= 4
|
|||
|
self.cursor_style = style
|
|||
|
self._update_cursor = True
|
|||
|
|
|||
|
def set_backlight(self, value: int) -> None:
|
|||
|
"""
|
|||
|
Set backlight brightness
|
|||
|
|
|||
|
value -- 0 to 100
|
|||
|
"""
|
|||
|
assert 0 <= value <= 100
|
|||
|
self.queue_command(self.CMD_BACKLIGHT, chr(value))
|
|||
|
|
|||
|
def set_lcd_contrast(self, value: int) -> None:
|
|||
|
"""
|
|||
|
value -- 0 to 255
|
|||
|
"""
|
|||
|
assert 0 <= value <= 255
|
|||
|
self.queue_command(self.CMD_LCD_CONTRAST, chr(value))
|
|||
|
|
|||
|
def set_led_pin(self, led: Literal[0, 1, 2, 3], rg: Literal[0, 1], value: int):
|
|||
|
"""
|
|||
|
led -- 0 to 3
|
|||
|
rg -- 0 for red, 1 for green
|
|||
|
value -- 0 to 100
|
|||
|
"""
|
|||
|
assert 0 <= led <= 3
|
|||
|
assert rg in (0, 1)
|
|||
|
assert 0 <= value <= 100
|
|||
|
self.queue_command(self.CMD_GPO, chr(12 - 2 * led - rg) + chr(value))
|