smartscreen-driver 0.2.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- 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
|