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