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.
@@ -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