smartscreen-driver 0.2.0__py3-none-any.whl
Sign up to get free protection for your applications and to get access to all the features.
- smartscreen_driver/lcd_comm.py +234 -0
- smartscreen_driver/lcd_comm_rev_a.py +225 -0
- smartscreen_driver/lcd_comm_rev_b.py +284 -0
- smartscreen_driver/lcd_comm_rev_c.py +409 -0
- smartscreen_driver/lcd_comm_rev_d.py +194 -0
- smartscreen_driver/lcd_simulated.py +168 -0
- smartscreen_driver/serialize.py +58 -0
- smartscreen_driver-0.2.0.dist-info/METADATA +32 -0
- smartscreen_driver-0.2.0.dist-info/RECORD +11 -0
- smartscreen_driver-0.2.0.dist-info/WHEEL +4 -0
- smartscreen_driver-0.2.0.dist-info/licenses/LICENSE +674 -0
@@ -0,0 +1,284 @@
|
|
1
|
+
# turing-smart-screen-python - a Python system monitor and library for USB-C displays like Turing Smart Screen or XuanFang
|
2
|
+
# https://github.com/mathoudebine/turing-smart-screen-python/
|
3
|
+
|
4
|
+
# Copyright (C) 2021-2023 Matthieu Houdebine (mathoudebine)
|
5
|
+
# Copyright (C) 2022-2023 Charles Ferguson (gerph)
|
6
|
+
#
|
7
|
+
# This program is free software: you can redistribute it and/or modify
|
8
|
+
# it under the terms of the GNU General Public License as published by
|
9
|
+
# the Free Software Foundation, either version 3 of the License, or
|
10
|
+
# (at your option) any later version.
|
11
|
+
#
|
12
|
+
# This program is distributed in the hope that it will be useful,
|
13
|
+
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
14
|
+
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
15
|
+
# GNU General Public License for more details.
|
16
|
+
#
|
17
|
+
# You should have received a copy of the GNU General Public License
|
18
|
+
# along with this program. If not, see <https://www.gnu.org/licenses/>.
|
19
|
+
|
20
|
+
import logging
|
21
|
+
from typing import Optional, Tuple
|
22
|
+
import queue
|
23
|
+
from enum import IntEnum
|
24
|
+
|
25
|
+
from serial.tools.list_ports import comports
|
26
|
+
from PIL import Image
|
27
|
+
|
28
|
+
from .lcd_comm import LcdComm, Orientation
|
29
|
+
from .serialize import image_to_rgb565, chunked
|
30
|
+
|
31
|
+
logger = logging.getLogger(__name__)
|
32
|
+
|
33
|
+
|
34
|
+
class Command(IntEnum):
|
35
|
+
HELLO = 0xCA # Establish communication before driving the screen
|
36
|
+
SET_ORIENTATION = 0xCB # Sets the screen orientation
|
37
|
+
DISPLAY_BITMAP = 0xCC # Displays an image on the screen
|
38
|
+
SET_LIGHTING = 0xCD # Sets the screen backplate RGB LED color
|
39
|
+
SET_BRIGHTNESS = 0xCE # Sets the screen brightness
|
40
|
+
|
41
|
+
|
42
|
+
# In revision B, basic orientations (portrait / landscape) are managed by the display
|
43
|
+
# The reverse orientations (reverse portrait / reverse landscape) are software-managed
|
44
|
+
class OrientationValueRevB(IntEnum):
|
45
|
+
ORIENTATION_PORTRAIT = 0x0
|
46
|
+
ORIENTATION_LANDSCAPE = 0x1
|
47
|
+
|
48
|
+
|
49
|
+
# HW revision B offers 4 sub-revisions to identify the HW capabilities
|
50
|
+
class SubRevision(IntEnum):
|
51
|
+
A01 = 0xA01 # HW revision B - brightness 0/1
|
52
|
+
A02 = 0xA02 # HW revision "flagship" - brightness 0/1
|
53
|
+
A11 = 0xA11 # HW revision B - brightness 0-255
|
54
|
+
A12 = 0xA12 # HW revision "flagship" - brightness 0-255
|
55
|
+
|
56
|
+
|
57
|
+
# This class is for XuanFang (rev. B & flagship) 3.5" screens
|
58
|
+
class LcdCommRevB(LcdComm):
|
59
|
+
def __init__(
|
60
|
+
self,
|
61
|
+
com_port: str = "AUTO",
|
62
|
+
display_width: int = 320,
|
63
|
+
display_height: int = 480,
|
64
|
+
update_queue: Optional[queue.Queue] = None,
|
65
|
+
):
|
66
|
+
logger.debug("HW revision: B")
|
67
|
+
LcdComm.__init__(self, com_port, display_width, display_height, update_queue)
|
68
|
+
self.open_serial()
|
69
|
+
self.sub_revision = (
|
70
|
+
SubRevision.A01
|
71
|
+
) # Run a Hello command to detect correct sub-rev.
|
72
|
+
|
73
|
+
def __del__(self):
|
74
|
+
self.close_serial()
|
75
|
+
|
76
|
+
def is_flagship(self):
|
77
|
+
return (
|
78
|
+
self.sub_revision == SubRevision.A02 or self.sub_revision == SubRevision.A12
|
79
|
+
)
|
80
|
+
|
81
|
+
def is_brightness_range(self):
|
82
|
+
return (
|
83
|
+
self.sub_revision == SubRevision.A11 or self.sub_revision == SubRevision.A12
|
84
|
+
)
|
85
|
+
|
86
|
+
@staticmethod
|
87
|
+
def auto_detect_com_port() -> Optional[str]:
|
88
|
+
com_ports = comports()
|
89
|
+
auto_com_port = None
|
90
|
+
|
91
|
+
for com_port in com_ports:
|
92
|
+
if com_port.serial_number == "2017-2-25":
|
93
|
+
auto_com_port = com_port.device
|
94
|
+
break
|
95
|
+
|
96
|
+
return auto_com_port
|
97
|
+
|
98
|
+
def send_command(self, cmd: Command, payload=None, bypass_queue: bool = False):
|
99
|
+
# New protocol (10 byte packets, framed with the command, 8 data bytes inside)
|
100
|
+
if payload is None:
|
101
|
+
payload = [0] * 8
|
102
|
+
elif len(payload) < 8:
|
103
|
+
payload = list(payload) + [0] * (8 - len(payload))
|
104
|
+
|
105
|
+
byte_buffer = bytearray(10)
|
106
|
+
byte_buffer[0] = cmd
|
107
|
+
byte_buffer[1] = payload[0]
|
108
|
+
byte_buffer[2] = payload[1]
|
109
|
+
byte_buffer[3] = payload[2]
|
110
|
+
byte_buffer[4] = payload[3]
|
111
|
+
byte_buffer[5] = payload[4]
|
112
|
+
byte_buffer[6] = payload[5]
|
113
|
+
byte_buffer[7] = payload[6]
|
114
|
+
byte_buffer[8] = payload[7]
|
115
|
+
byte_buffer[9] = cmd
|
116
|
+
|
117
|
+
# If no queue for async requests, or if asked explicitly to do the request sequentially: do request now
|
118
|
+
if not self.update_queue or bypass_queue:
|
119
|
+
self.write_data(byte_buffer)
|
120
|
+
else:
|
121
|
+
# Lock queue mutex then queue the request
|
122
|
+
with self.update_queue_mutex:
|
123
|
+
self.update_queue.put((self.write_data, [byte_buffer]))
|
124
|
+
|
125
|
+
def _hello(self):
|
126
|
+
hello = [ord("H"), ord("E"), ord("L"), ord("L"), ord("O")]
|
127
|
+
|
128
|
+
# This command reads LCD answer on serial link, so it bypasses the queue
|
129
|
+
self.send_command(Command.HELLO, payload=hello, bypass_queue=True)
|
130
|
+
response = self.serial_read(10)
|
131
|
+
self.serial_flush_input()
|
132
|
+
|
133
|
+
if len(response) != 10:
|
134
|
+
logger.warning("Device not recognised (short response to HELLO)")
|
135
|
+
assert response, "Device did not return anything"
|
136
|
+
if response[0] != Command.HELLO or response[-1] != Command.HELLO:
|
137
|
+
logger.warning("Device not recognised (bad framing)")
|
138
|
+
if [x for x in response[1:6]] != hello:
|
139
|
+
logger.warning(
|
140
|
+
"Device not recognised (No HELLO; got %r)" % (response[1:6],)
|
141
|
+
)
|
142
|
+
# The HELLO response here is followed by 2 bytes
|
143
|
+
# This is the screen version (not like the revision which is B/flagship)
|
144
|
+
# The version is used to determine what capabilities the screen offers (see SubRevision class above)
|
145
|
+
if response[6] == 0xA:
|
146
|
+
if response[7] == 0x01:
|
147
|
+
self.sub_revision = SubRevision.A01
|
148
|
+
elif response[7] == 0x02:
|
149
|
+
self.sub_revision = SubRevision.A02
|
150
|
+
elif response[7] == 0x11:
|
151
|
+
self.sub_revision = SubRevision.A11
|
152
|
+
elif response[7] == 0x12:
|
153
|
+
self.sub_revision = SubRevision.A12
|
154
|
+
else:
|
155
|
+
logger.warning("Display returned unknown sub-revision on Hello answer")
|
156
|
+
|
157
|
+
logger.debug("HW sub-revision: %s" % (str(self.sub_revision)))
|
158
|
+
|
159
|
+
def initialize_comm(self):
|
160
|
+
self._hello()
|
161
|
+
|
162
|
+
def reset(self):
|
163
|
+
# HW revision B does not implement a command to reset it: clear display instead
|
164
|
+
self.clear()
|
165
|
+
|
166
|
+
def clear(self):
|
167
|
+
# HW revision B does not implement a Clear command: display a blank image on the whole screen
|
168
|
+
# Force an orientation in case the screen is currently configured with one different from the theme
|
169
|
+
backup_orientation = self.orientation
|
170
|
+
self.set_orientation(orientation=Orientation.PORTRAIT)
|
171
|
+
|
172
|
+
blank = Image.new("RGB", (self.width(), self.height()), (255, 255, 255))
|
173
|
+
self.paint(blank)
|
174
|
+
|
175
|
+
# Restore orientation
|
176
|
+
self.set_orientation(orientation=backup_orientation)
|
177
|
+
|
178
|
+
def screen_off(self):
|
179
|
+
# HW revision B does not implement a "ScreenOff" native command: using SetBrightness(0) instead
|
180
|
+
self.set_brightness(0)
|
181
|
+
|
182
|
+
def screen_on(self):
|
183
|
+
# HW revision B does not implement a "ScreenOn" native command: using SetBrightness() instead
|
184
|
+
self.set_brightness()
|
185
|
+
|
186
|
+
def set_brightness(self, level: int = 25):
|
187
|
+
assert 0 <= level <= 100, "Brightness level must be [0-100]"
|
188
|
+
|
189
|
+
if self.is_brightness_range():
|
190
|
+
# Brightness scales from 0 to 255, with 255 being the brightest and 0 being the darkest.
|
191
|
+
# Convert our brightness % to an absolute value.
|
192
|
+
converted_level = int((level / 100) * 255)
|
193
|
+
else:
|
194
|
+
# Brightness is 1 (off) or 0 (full brightness)
|
195
|
+
logger.info("Your display does not support custom brightness level")
|
196
|
+
converted_level = 1 if level == 0 else 0
|
197
|
+
|
198
|
+
self.send_command(Command.SET_BRIGHTNESS, payload=[converted_level])
|
199
|
+
|
200
|
+
def set_backplate_led_color(
|
201
|
+
self, led_color: Tuple[int, int, int] = (255, 255, 255)
|
202
|
+
):
|
203
|
+
if self.is_flagship():
|
204
|
+
self.send_command(Command.SET_LIGHTING, payload=list(led_color))
|
205
|
+
else:
|
206
|
+
logger.info(
|
207
|
+
"Only HW revision 'flagship' supports backplate LED color setting"
|
208
|
+
)
|
209
|
+
|
210
|
+
def set_orientation(self, orientation: Orientation = Orientation.PORTRAIT):
|
211
|
+
# In revision B, basic orientations (portrait / landscape) are managed by the display
|
212
|
+
# The reverse orientations (reverse portrait / reverse landscape) are software-managed
|
213
|
+
self.orientation = orientation
|
214
|
+
if (
|
215
|
+
self.orientation == Orientation.PORTRAIT
|
216
|
+
or self.orientation == Orientation.REVERSE_PORTRAIT
|
217
|
+
):
|
218
|
+
self.send_command(
|
219
|
+
Command.SET_ORIENTATION,
|
220
|
+
payload=[OrientationValueRevB.ORIENTATION_PORTRAIT],
|
221
|
+
)
|
222
|
+
else:
|
223
|
+
self.send_command(
|
224
|
+
Command.SET_ORIENTATION,
|
225
|
+
payload=[OrientationValueRevB.ORIENTATION_LANDSCAPE],
|
226
|
+
)
|
227
|
+
|
228
|
+
def serialize_image(self, image: Image.Image, height: int, width: int) -> bytes:
|
229
|
+
if image.width != width or image.height != height:
|
230
|
+
image = image.crop((0, 0, width, height))
|
231
|
+
if (
|
232
|
+
self.orientation == Orientation.REVERSE_PORTRAIT
|
233
|
+
or self.orientation == Orientation.REVERSE_LANDSCAPE
|
234
|
+
):
|
235
|
+
image = image.rotate(180)
|
236
|
+
return image_to_rgb565(image, "big")
|
237
|
+
|
238
|
+
def paint(
|
239
|
+
self,
|
240
|
+
image: Image.Image,
|
241
|
+
pos: Tuple[int, int] = (0, 0),
|
242
|
+
):
|
243
|
+
image = self._crop_to_display_bounds(image, pos)
|
244
|
+
image_width, image_height = image.size[0], image.size[1]
|
245
|
+
|
246
|
+
if image_height == 0 or image_width == 0:
|
247
|
+
return
|
248
|
+
|
249
|
+
x, y = pos
|
250
|
+
if (
|
251
|
+
self.orientation == Orientation.PORTRAIT
|
252
|
+
or self.orientation == Orientation.LANDSCAPE
|
253
|
+
):
|
254
|
+
x0, y0 = x, y
|
255
|
+
x1, y1 = x + image_width - 1, y + image_height - 1
|
256
|
+
else:
|
257
|
+
# Reverse landscape/portrait orientations are software-managed: get new coordinates
|
258
|
+
x0, y0 = (
|
259
|
+
self.width() - x - image_width,
|
260
|
+
self.height() - y - image_height,
|
261
|
+
)
|
262
|
+
x1, y1 = self.width() - x - 1, self.height() - y - 1
|
263
|
+
|
264
|
+
self.send_command(
|
265
|
+
Command.DISPLAY_BITMAP,
|
266
|
+
payload=[
|
267
|
+
(x0 >> 8) & 255,
|
268
|
+
x0 & 255,
|
269
|
+
(y0 >> 8) & 255,
|
270
|
+
y0 & 255,
|
271
|
+
(x1 >> 8) & 255,
|
272
|
+
x1 & 255,
|
273
|
+
(y1 >> 8) & 255,
|
274
|
+
y1 & 255,
|
275
|
+
],
|
276
|
+
)
|
277
|
+
|
278
|
+
rgb565be = self.serialize_image(image, image_height, image_width)
|
279
|
+
|
280
|
+
# Lock queue mutex then queue all the requests for the image data
|
281
|
+
with self.update_queue_mutex:
|
282
|
+
# Send image data by multiple of "display width" bytes
|
283
|
+
for chunk in chunked(rgb565be, self.width() * 8):
|
284
|
+
self.send_line(chunk)
|
@@ -0,0 +1,409 @@
|
|
1
|
+
# turing-smart-screen-python - a Python system monitor and library for USB-C displays like Turing Smart Screen or XuanFang
|
2
|
+
# https://github.com/mathoudebine/turing-smart-screen-python/
|
3
|
+
|
4
|
+
# Copyright (C) 2021-2023 Matthieu Houdebine (mathoudebine)
|
5
|
+
# Copyright (C) 2023-2023 Alex W. Baulé (alexwbaule)
|
6
|
+
# Copyright (C) 2023-2023 Arthur Ferrai (arthurferrai)
|
7
|
+
#
|
8
|
+
# This program is free software: you can redistribute it and/or modify
|
9
|
+
# it under the terms of the GNU General Public License as published by
|
10
|
+
# the Free Software Foundation, either version 3 of the License, or
|
11
|
+
# (at your option) any later version.
|
12
|
+
#
|
13
|
+
# This program is distributed in the hope that it will be useful,
|
14
|
+
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
15
|
+
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
16
|
+
# GNU General Public License for more details.
|
17
|
+
#
|
18
|
+
# You should have received a copy of the GNU General Public License
|
19
|
+
# along with this program. If not, see <https://www.gnu.org/licenses/>.
|
20
|
+
|
21
|
+
import queue
|
22
|
+
import time
|
23
|
+
from enum import Enum
|
24
|
+
from math import ceil
|
25
|
+
from typing import Optional, Tuple
|
26
|
+
import logging
|
27
|
+
|
28
|
+
import serial
|
29
|
+
from PIL import Image
|
30
|
+
from serial.tools.list_ports import comports
|
31
|
+
|
32
|
+
from .lcd_comm import Orientation, LcdComm
|
33
|
+
from .serialize import image_to_bgra, image_to_bgr, chunked
|
34
|
+
|
35
|
+
logger = logging.getLogger(__name__)
|
36
|
+
|
37
|
+
|
38
|
+
class Count:
|
39
|
+
Start = 0
|
40
|
+
|
41
|
+
|
42
|
+
# READ HELLO ALWAYS IS 23.
|
43
|
+
# ALL READS IS 1024
|
44
|
+
|
45
|
+
# ORDER:
|
46
|
+
# SEND HELLO
|
47
|
+
# READ HELLO (23)
|
48
|
+
# SEND STOP_VIDEO
|
49
|
+
# SEND STOP_MEDIA
|
50
|
+
# READ STATUS (1024)
|
51
|
+
# SEND SET_BRIGHTNESS
|
52
|
+
# SEND SET_OPTIONS WITH ORIENTATION ?
|
53
|
+
# SEND PRE_UPDATE_BITMAP
|
54
|
+
# SEND START_DISPLAY_BITMAP
|
55
|
+
# SEND DISPLAY_BITMAP
|
56
|
+
# READ STATUS (1024)
|
57
|
+
# SEND QUERY_STATUS
|
58
|
+
# READ STATUS (1024)
|
59
|
+
# WHILE:
|
60
|
+
# SEND UPDATE_BITMAP
|
61
|
+
# SEND QUERY_STATUS
|
62
|
+
# READ STATUS(1024)
|
63
|
+
|
64
|
+
|
65
|
+
class Command(Enum):
|
66
|
+
# COMMANDS
|
67
|
+
HELLO = bytearray(
|
68
|
+
(0x01, 0xEF, 0x69, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0xC5, 0xD3)
|
69
|
+
)
|
70
|
+
OPTIONS = bytearray(
|
71
|
+
(0x7D, 0xEF, 0x69, 0x00, 0x00, 0x00, 0x05, 0x00, 0x00, 0x00, 0x2D)
|
72
|
+
)
|
73
|
+
RESTART = bytearray((0x84, 0xEF, 0x69, 0x00, 0x00, 0x00, 0x01))
|
74
|
+
TURNOFF = bytearray((0x83, 0xEF, 0x69, 0x00, 0x00, 0x00, 0x01))
|
75
|
+
TURNON = bytearray((0x83, 0xEF, 0x69, 0x00, 0x00, 0x00, 0x00))
|
76
|
+
|
77
|
+
SET_BRIGHTNESS = bytearray(
|
78
|
+
(0x7B, 0xEF, 0x69, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00)
|
79
|
+
)
|
80
|
+
|
81
|
+
# STOP COMMANDS
|
82
|
+
STOP_VIDEO = bytearray((0x79, 0xEF, 0x69, 0x00, 0x00, 0x00, 0x01))
|
83
|
+
STOP_MEDIA = bytearray((0x96, 0xEF, 0x69, 0x00, 0x00, 0x00, 0x01))
|
84
|
+
|
85
|
+
# IMAGE QUERY STATUS
|
86
|
+
QUERY_STATUS = bytearray((0xCF, 0xEF, 0x69, 0x00, 0x00, 0x00, 0x01))
|
87
|
+
|
88
|
+
# STATIC IMAGE
|
89
|
+
START_DISPLAY_BITMAP = bytearray((0x2C,))
|
90
|
+
PRE_UPDATE_BITMAP = bytearray((0x86, 0xEF, 0x69, 0x00, 0x00, 0x00, 0x01))
|
91
|
+
UPDATE_BITMAP = bytearray((0xCC, 0xEF, 0x69, 0x00))
|
92
|
+
|
93
|
+
RESTARTSCREEN = bytearray((0x84, 0xEF, 0x69, 0x00, 0x00, 0x00, 0x01))
|
94
|
+
DISPLAY_BITMAP = bytearray((0xC8, 0xEF, 0x69, 0x00, 0x17, 0x70))
|
95
|
+
|
96
|
+
STARTMODE_DEFAULT = bytearray((0x00,))
|
97
|
+
STARTMODE_IMAGE = bytearray((0x01,))
|
98
|
+
STARTMODE_VIDEO = bytearray((0x02,))
|
99
|
+
FLIP_180 = bytearray((0x01,))
|
100
|
+
NO_FLIP = bytearray((0x00,))
|
101
|
+
SEND_PAYLOAD = bytearray((0xFF,))
|
102
|
+
|
103
|
+
def __init__(self, command):
|
104
|
+
self.command = command
|
105
|
+
|
106
|
+
|
107
|
+
class Padding(Enum):
|
108
|
+
NULL = bytearray([0x00])
|
109
|
+
START_DISPLAY_BITMAP = bytearray([0x2C])
|
110
|
+
|
111
|
+
def __init__(self, command):
|
112
|
+
self.command = command
|
113
|
+
|
114
|
+
|
115
|
+
class SleepInterval(Enum):
|
116
|
+
OFF = bytearray((0x00,))
|
117
|
+
ONE = bytearray((0x01,))
|
118
|
+
TWO = bytearray((0x02,))
|
119
|
+
THREE = bytearray((0x03,))
|
120
|
+
FOUR = bytearray((0x04,))
|
121
|
+
FIVE = bytearray((0x05,))
|
122
|
+
SIX = bytearray((0x06,))
|
123
|
+
SEVEN = bytearray((0x07,))
|
124
|
+
EIGHT = bytearray((0x08,))
|
125
|
+
NINE = bytearray((0x09,))
|
126
|
+
TEN = bytearray((0x0A,))
|
127
|
+
|
128
|
+
def __init__(self, command):
|
129
|
+
self.command = command
|
130
|
+
|
131
|
+
|
132
|
+
class SubRevision(Enum):
|
133
|
+
UNKNOWN = ""
|
134
|
+
FIVEINCH = "chs_5inch"
|
135
|
+
|
136
|
+
def __init__(self, command):
|
137
|
+
self.command = command
|
138
|
+
|
139
|
+
|
140
|
+
# This class is for Turing Smart Screen 5" screens
|
141
|
+
class LcdCommRevC(LcdComm):
|
142
|
+
def __init__(
|
143
|
+
self,
|
144
|
+
com_port: str = "AUTO",
|
145
|
+
display_width: int = 480,
|
146
|
+
display_height: int = 800,
|
147
|
+
update_queue: Optional[queue.Queue] = None,
|
148
|
+
):
|
149
|
+
logger.debug("HW revision: C")
|
150
|
+
LcdComm.__init__(self, com_port, display_width, display_height, update_queue)
|
151
|
+
self.open_serial()
|
152
|
+
|
153
|
+
def __del__(self):
|
154
|
+
self.close_serial()
|
155
|
+
|
156
|
+
@staticmethod
|
157
|
+
def auto_detect_com_port() -> Optional[str]:
|
158
|
+
com_ports = comports()
|
159
|
+
|
160
|
+
for com_port in com_ports:
|
161
|
+
if com_port.serial_number == "USB7INCH":
|
162
|
+
LcdCommRevC._connect_to_reset_device_name(com_port)
|
163
|
+
return LcdCommRevC.auto_detect_com_port()
|
164
|
+
if com_port.serial_number == "20080411":
|
165
|
+
return com_port.device
|
166
|
+
|
167
|
+
return None
|
168
|
+
|
169
|
+
@staticmethod
|
170
|
+
def _connect_to_reset_device_name(com_port):
|
171
|
+
# this device enumerates differently when off, we need to connect once to reset it to correct COM device
|
172
|
+
try:
|
173
|
+
logger.debug(f"Waiting for device {com_port} to be turned ON...")
|
174
|
+
serial.Serial(com_port.device, 115200, timeout=1, rtscts=True)
|
175
|
+
except serial.SerialException:
|
176
|
+
pass
|
177
|
+
time.sleep(10)
|
178
|
+
|
179
|
+
def _send_command(
|
180
|
+
self,
|
181
|
+
cmd: Command,
|
182
|
+
payload: Optional[bytearray] = None,
|
183
|
+
padding: Optional[Padding] = None,
|
184
|
+
bypass_queue: bool = False,
|
185
|
+
readsize: Optional[int] = None,
|
186
|
+
):
|
187
|
+
message = bytearray()
|
188
|
+
|
189
|
+
if cmd != Command.SEND_PAYLOAD:
|
190
|
+
message = bytearray(cmd.value)
|
191
|
+
|
192
|
+
# logger.debug("Command: {}".format(cmd.name))
|
193
|
+
|
194
|
+
if not padding:
|
195
|
+
padding = Padding.NULL
|
196
|
+
|
197
|
+
if payload:
|
198
|
+
message.extend(payload)
|
199
|
+
|
200
|
+
msg_size = len(message)
|
201
|
+
|
202
|
+
if not (msg_size / 250).is_integer():
|
203
|
+
pad_size = 250 * ceil(msg_size / 250) - msg_size
|
204
|
+
message += bytearray(padding.value * pad_size)
|
205
|
+
|
206
|
+
# If no queue for async requests, or if asked explicitly to do the request sequentially: do request now
|
207
|
+
if not self.update_queue or bypass_queue:
|
208
|
+
self.write_data(message)
|
209
|
+
if readsize:
|
210
|
+
self.read_data(readsize)
|
211
|
+
else:
|
212
|
+
# Lock queue mutex then queue the request
|
213
|
+
self.update_queue.put((self.write_data, [message]))
|
214
|
+
if readsize:
|
215
|
+
self.update_queue.put((self.read_data, [readsize]))
|
216
|
+
|
217
|
+
def _hello(self):
|
218
|
+
# This command reads LCD answer on serial link, so it bypasses the queue
|
219
|
+
self.sub_revision = SubRevision.UNKNOWN
|
220
|
+
self._send_command(Command.HELLO, bypass_queue=True)
|
221
|
+
response = str(self.serial_read(22).decode())
|
222
|
+
self.serial_flush_input()
|
223
|
+
if response.startswith(SubRevision.FIVEINCH.value):
|
224
|
+
self.sub_revision = SubRevision.FIVEINCH
|
225
|
+
else:
|
226
|
+
logger.warning(
|
227
|
+
"Display returned unknown sub-revision on Hello answer (%s)"
|
228
|
+
% str(response)
|
229
|
+
)
|
230
|
+
|
231
|
+
logger.debug("HW sub-revision: %s" % (str(self.sub_revision)))
|
232
|
+
|
233
|
+
def initialize_comm(self):
|
234
|
+
self._hello()
|
235
|
+
|
236
|
+
def reset(self):
|
237
|
+
logger.info("Display reset (COM port may change)...")
|
238
|
+
# Reset command bypasses queue because it is run when queue threads are not yet started
|
239
|
+
self._send_command(Command.RESTART, bypass_queue=True)
|
240
|
+
self.close_serial()
|
241
|
+
# Wait for display reset then reconnect
|
242
|
+
time.sleep(15)
|
243
|
+
self.open_serial()
|
244
|
+
|
245
|
+
def clear(self):
|
246
|
+
# This hardware does not implement a Clear command: display a blank image on the whole screen
|
247
|
+
# Force an orientation in case the screen is currently configured with one different from the theme
|
248
|
+
backup_orientation = self.orientation
|
249
|
+
self.set_orientation(orientation=Orientation.PORTRAIT)
|
250
|
+
|
251
|
+
blank = Image.new("RGB", (self.width(), self.height()), (255, 255, 255))
|
252
|
+
self.paint(blank)
|
253
|
+
|
254
|
+
# Restore orientation
|
255
|
+
self.set_orientation(orientation=backup_orientation)
|
256
|
+
|
257
|
+
def screen_off(self):
|
258
|
+
logger.info("Calling ScreenOff")
|
259
|
+
self._send_command(Command.STOP_VIDEO)
|
260
|
+
self._send_command(Command.STOP_MEDIA, readsize=1024)
|
261
|
+
self._send_command(Command.TURNOFF)
|
262
|
+
|
263
|
+
def screen_on(self):
|
264
|
+
logger.info("Calling ScreenOn")
|
265
|
+
self._send_command(Command.STOP_VIDEO)
|
266
|
+
self._send_command(Command.STOP_MEDIA, readsize=1024)
|
267
|
+
# self._send_command(Command.SET_BRIGHTNESS, payload=bytearray([255]))
|
268
|
+
|
269
|
+
def set_brightness(self, level: int = 25):
|
270
|
+
# logger.info("Call SetBrightness")
|
271
|
+
assert 0 <= level <= 100, "Brightness level must be [0-100]"
|
272
|
+
|
273
|
+
# Brightness scales from 0 to 255, with 255 being the brightest and 0 being the darkest.
|
274
|
+
# Convert our brightness % to an absolute value.
|
275
|
+
converted_level = int((level / 100) * 255)
|
276
|
+
|
277
|
+
self._send_command(
|
278
|
+
Command.SET_BRIGHTNESS,
|
279
|
+
payload=bytearray((converted_level,)),
|
280
|
+
bypass_queue=True,
|
281
|
+
)
|
282
|
+
|
283
|
+
def set_orientation(self, orientation: Orientation = Orientation.PORTRAIT):
|
284
|
+
self.orientation = orientation
|
285
|
+
# logger.info(f"Call SetOrientation to: {self.orientation.name}")
|
286
|
+
|
287
|
+
if (
|
288
|
+
self.orientation == Orientation.REVERSE_LANDSCAPE
|
289
|
+
or self.orientation == Orientation.REVERSE_PORTRAIT
|
290
|
+
):
|
291
|
+
b = (
|
292
|
+
Command.STARTMODE_DEFAULT.value
|
293
|
+
+ Padding.NULL.value
|
294
|
+
+ Command.FLIP_180.value
|
295
|
+
+ SleepInterval.OFF.value
|
296
|
+
)
|
297
|
+
self._send_command(Command.OPTIONS, payload=b)
|
298
|
+
else:
|
299
|
+
b = (
|
300
|
+
Command.STARTMODE_DEFAULT.value
|
301
|
+
+ Padding.NULL.value
|
302
|
+
+ Command.NO_FLIP.value
|
303
|
+
+ SleepInterval.OFF.value
|
304
|
+
)
|
305
|
+
self._send_command(Command.OPTIONS, payload=b)
|
306
|
+
|
307
|
+
def paint(
|
308
|
+
self,
|
309
|
+
image: Image.Image,
|
310
|
+
pos: Tuple[int, int] = (0, 0),
|
311
|
+
):
|
312
|
+
image = self._crop_to_display_bounds(image, pos)
|
313
|
+
image_width, image_height = image.size[0], image.size[1]
|
314
|
+
|
315
|
+
if image_height == 0 or image_width == 0:
|
316
|
+
return
|
317
|
+
|
318
|
+
x, y = pos
|
319
|
+
|
320
|
+
if (
|
321
|
+
x == 0
|
322
|
+
and y == 0
|
323
|
+
and (image_width == self.width())
|
324
|
+
and (image_height == self.height())
|
325
|
+
):
|
326
|
+
with self.update_queue_mutex:
|
327
|
+
self._send_command(Command.PRE_UPDATE_BITMAP)
|
328
|
+
self._send_command(
|
329
|
+
Command.START_DISPLAY_BITMAP, padding=Padding.START_DISPLAY_BITMAP
|
330
|
+
)
|
331
|
+
self._send_command(Command.DISPLAY_BITMAP)
|
332
|
+
self._send_command(
|
333
|
+
Command.SEND_PAYLOAD,
|
334
|
+
payload=bytearray(self._generate_full_image(image)),
|
335
|
+
readsize=1024,
|
336
|
+
)
|
337
|
+
self._send_command(Command.QUERY_STATUS, readsize=1024)
|
338
|
+
else:
|
339
|
+
with self.update_queue_mutex:
|
340
|
+
img, pyd = self._generate_update_image(
|
341
|
+
image, x, y, Count.Start, Command.UPDATE_BITMAP
|
342
|
+
)
|
343
|
+
self._send_command(Command.SEND_PAYLOAD, payload=pyd)
|
344
|
+
self._send_command(Command.SEND_PAYLOAD, payload=img)
|
345
|
+
self._send_command(Command.QUERY_STATUS, readsize=1024)
|
346
|
+
Count.Start += 1
|
347
|
+
|
348
|
+
def _generate_full_image(self, image: Image.Image) -> bytes:
|
349
|
+
if self.orientation == Orientation.PORTRAIT:
|
350
|
+
image = image.rotate(90, expand=True)
|
351
|
+
elif self.orientation == Orientation.REVERSE_PORTRAIT:
|
352
|
+
image = image.rotate(270, expand=True)
|
353
|
+
elif self.orientation == Orientation.REVERSE_LANDSCAPE:
|
354
|
+
image = image.rotate(180)
|
355
|
+
|
356
|
+
bgra_data = image_to_bgra(image)
|
357
|
+
|
358
|
+
return b"\x00".join(chunked(bgra_data, 249))
|
359
|
+
|
360
|
+
def _generate_update_image(
|
361
|
+
self,
|
362
|
+
image: Image.Image,
|
363
|
+
x: int,
|
364
|
+
y: int,
|
365
|
+
count: int,
|
366
|
+
cmd: Optional[Command] = None,
|
367
|
+
) -> Tuple[bytearray, bytearray]:
|
368
|
+
x0, y0 = x, y
|
369
|
+
|
370
|
+
if self.orientation == Orientation.PORTRAIT:
|
371
|
+
image = image.rotate(90, expand=True)
|
372
|
+
x0 = self.width() - x - image.height
|
373
|
+
elif self.orientation == Orientation.REVERSE_PORTRAIT:
|
374
|
+
image = image.rotate(270, expand=True)
|
375
|
+
y0 = self.height() - y - image.width
|
376
|
+
elif self.orientation == Orientation.REVERSE_LANDSCAPE:
|
377
|
+
image = image.rotate(180, expand=True)
|
378
|
+
y0 = self.width() - x - image.width
|
379
|
+
x0 = self.height() - y - image.height
|
380
|
+
elif self.orientation == Orientation.LANDSCAPE:
|
381
|
+
x0, y0 = y, x
|
382
|
+
|
383
|
+
img_raw_data = bytearray()
|
384
|
+
bgr_data = image_to_bgr(image)
|
385
|
+
for h, line in enumerate(chunked(bgr_data, image.width * 3)):
|
386
|
+
img_raw_data += int(((x0 + h) * self.display_height) + y0).to_bytes(
|
387
|
+
3, "big"
|
388
|
+
)
|
389
|
+
img_raw_data += int(image.width).to_bytes(2, "big")
|
390
|
+
img_raw_data += line
|
391
|
+
|
392
|
+
image_size = int(len(img_raw_data) + 2).to_bytes(
|
393
|
+
3, "big"
|
394
|
+
) # The +2 is for the "ef69" that will be added later.
|
395
|
+
|
396
|
+
# logger.debug("Render Count: {}".format(count))
|
397
|
+
payload = bytearray()
|
398
|
+
|
399
|
+
if cmd:
|
400
|
+
payload.extend(cmd.value)
|
401
|
+
payload.extend(image_size)
|
402
|
+
payload.extend(Padding.NULL.value * 3)
|
403
|
+
payload.extend(count.to_bytes(4, "big"))
|
404
|
+
|
405
|
+
if len(img_raw_data) > 250:
|
406
|
+
img_raw_data = bytearray(b"\x00").join(chunked(bytes(img_raw_data), 249))
|
407
|
+
img_raw_data += b"\xef\x69"
|
408
|
+
|
409
|
+
return img_raw_data, payload
|