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,234 @@
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
+ #
6
+ # This program is free software: you can redistribute it and/or modify
7
+ # it under the terms of the GNU General Public License as published by
8
+ # the Free Software Foundation, either version 3 of the License, or
9
+ # (at your option) any later version.
10
+ #
11
+ # This program is distributed in the hope that it will be useful,
12
+ # but WITHOUT ANY WARRANTY; without even the implied warranty of
13
+ # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
14
+ # GNU General Public License for more details.
15
+ #
16
+ # You should have received a copy of the GNU General Public License
17
+ # along with this program. If not, see <https://www.gnu.org/licenses/>.
18
+
19
+ import queue
20
+ import threading
21
+ import time
22
+ from abc import ABC, abstractmethod
23
+ from enum import IntEnum
24
+ from typing import Tuple, Optional
25
+ import logging
26
+
27
+ import serial
28
+ from PIL import Image
29
+
30
+ logger = logging.getLogger(__name__)
31
+
32
+
33
+ class Orientation(IntEnum):
34
+ PORTRAIT = 0
35
+ LANDSCAPE = 2
36
+ REVERSE_PORTRAIT = 1
37
+ REVERSE_LANDSCAPE = 3
38
+
39
+
40
+ class ComPortDetectError(Exception):
41
+ pass
42
+
43
+
44
+ class LcdComm(ABC):
45
+ def __init__(
46
+ self,
47
+ com_port: str = "AUTO",
48
+ display_width: int = 320,
49
+ display_height: int = 480,
50
+ update_queue: Optional[queue.Queue] = None,
51
+ ):
52
+ self.lcd_serial = None
53
+
54
+ # String containing absolute path to serial port e.g. "COM3", "/dev/ttyACM1" or "AUTO" for auto-discovery
55
+ self.com_port = com_port
56
+
57
+ # Display always start in portrait orientation by default
58
+ self.orientation = Orientation.PORTRAIT
59
+ # Display width in default orientation (portrait)
60
+ self.display_width = display_width
61
+ # Display height in default orientation (portrait)
62
+ self.display_height = display_height
63
+
64
+ # Queue containing the serial requests to send to the screen. An external thread should run to process requests
65
+ # on the queue. If you want serial requests to be done in sequence, set it to None
66
+ self.update_queue = update_queue
67
+
68
+ # Mutex to protect the queue in case a thread want to add multiple requests (e.g. image data) that should not be
69
+ # mixed with other requests in-between
70
+ self.update_queue_mutex = threading.Lock()
71
+
72
+ def width(self) -> int:
73
+ if (
74
+ self.orientation == Orientation.PORTRAIT
75
+ or self.orientation == Orientation.REVERSE_PORTRAIT
76
+ ):
77
+ return self.display_width
78
+ else:
79
+ return self.display_height
80
+
81
+ def height(self) -> int:
82
+ if (
83
+ self.orientation == Orientation.PORTRAIT
84
+ or self.orientation == Orientation.REVERSE_PORTRAIT
85
+ ):
86
+ return self.display_height
87
+ else:
88
+ return self.display_width
89
+
90
+ def size(self) -> Tuple[int, int]:
91
+ return self.width(), self.height()
92
+
93
+ def open_serial(self):
94
+ if self.com_port == "AUTO":
95
+ self.com_port = self.auto_detect_com_port()
96
+ if not self.com_port:
97
+ raise ComPortDetectError("No COM port detected")
98
+ else:
99
+ logger.debug(f"Auto detected COM port: {self.com_port}")
100
+ else:
101
+ logger.debug(f"Static COM port: {self.com_port}")
102
+
103
+ self.lcd_serial = serial.Serial(self.com_port, 115200, timeout=1, rtscts=True)
104
+
105
+ def close_serial(self):
106
+ if self.lcd_serial is not None:
107
+ self.lcd_serial.close()
108
+
109
+ def serial_write(self, data: bytes):
110
+ assert self.lcd_serial is not None
111
+ self.lcd_serial.write(data)
112
+
113
+ def serial_read(self, size: int) -> bytes:
114
+ assert self.lcd_serial is not None
115
+ return self.lcd_serial.read(size)
116
+
117
+ def serial_flush_input(self):
118
+ if self.lcd_serial is not None:
119
+ self.lcd_serial.reset_input_buffer()
120
+
121
+ def write_data(self, data: bytearray):
122
+ self.write_line(bytes(data))
123
+
124
+ def send_line(self, line: bytes):
125
+ if self.update_queue:
126
+ # Queue the request. Mutex is locked by caller to queue multiple lines
127
+ self.update_queue.put((self.write_line, [line]))
128
+ else:
129
+ # If no queue for async requests: do request now
130
+ self.write_line(line)
131
+
132
+ def write_line(self, line: bytes):
133
+ try:
134
+ self.serial_write(line)
135
+ except serial.SerialTimeoutException:
136
+ # We timed-out trying to write to our device, slow things down.
137
+ logger.warning("(Write line) Too fast! Slow down!")
138
+ except serial.SerialException:
139
+ # Error writing data to device: close and reopen serial port, try to write again
140
+ logger.error(
141
+ "SerialException: Failed to send serial data to device. Closing and reopening COM port before retrying once."
142
+ )
143
+ self.close_serial()
144
+ time.sleep(1)
145
+ self.open_serial()
146
+ self.serial_write(line)
147
+
148
+ def read_data(self, size: int):
149
+ try:
150
+ response = self.serial_read(size)
151
+ # logger.debug("Received: [{}]".format(str(response, 'utf-8')))
152
+ return response
153
+ except serial.SerialTimeoutException:
154
+ # We timed-out trying to read from our device, slow things down.
155
+ logger.warning("(Read data) Too fast! Slow down!")
156
+ except serial.SerialException:
157
+ # Error writing data to device: close and reopen serial port, try to read again
158
+ logger.error(
159
+ "SerialException: Failed to read serial data from device. Closing and reopening COM port before retrying once."
160
+ )
161
+ self.close_serial()
162
+ time.sleep(1)
163
+ self.open_serial()
164
+ return self.serial_read(size)
165
+
166
+ @staticmethod
167
+ @abstractmethod
168
+ def auto_detect_com_port() -> Optional[str]:
169
+ pass
170
+
171
+ @abstractmethod
172
+ def initialize_comm(self):
173
+ pass
174
+
175
+ @abstractmethod
176
+ def reset(self):
177
+ pass
178
+
179
+ @abstractmethod
180
+ def clear(self):
181
+ pass
182
+
183
+ @abstractmethod
184
+ def screen_off(self):
185
+ pass
186
+
187
+ @abstractmethod
188
+ def screen_on(self):
189
+ pass
190
+
191
+ @abstractmethod
192
+ def set_brightness(self, level: int):
193
+ pass
194
+
195
+ def set_backplate_led_color(
196
+ self, led_color: Tuple[int, int, int] = (255, 255, 255)
197
+ ):
198
+ pass
199
+
200
+ @abstractmethod
201
+ def set_orientation(self, orientation: Orientation):
202
+ pass
203
+
204
+ def _crop_to_display_bounds(
205
+ self,
206
+ image: Image.Image,
207
+ pos: Tuple[int, int] = (0, 0),
208
+ ) -> Image.Image:
209
+ width, height = self.size()
210
+ x, y = pos
211
+ image_width, image_height = image.size[0], image.size[1]
212
+
213
+ assert 0 <= x < width, "x position not within display bounds"
214
+ assert 0 <= y < height, "y position not within display bounds"
215
+
216
+ # If our image size + the (x, y) position offsets are bigger than
217
+ # our display, reduce the image size to fit our screen
218
+ if x + image_width > width:
219
+ image_width = width - x
220
+ if y + image_height > height:
221
+ image_height = height - y
222
+
223
+ if image_width != image.size[0] or image_height != image.size[1]:
224
+ image = image.crop((0, 0, image_width, image_height))
225
+
226
+ return image
227
+
228
+ @abstractmethod
229
+ def paint(
230
+ self,
231
+ image: Image.Image,
232
+ pos: Tuple[int, int] = (0, 0),
233
+ ):
234
+ pass
@@ -0,0 +1,225 @@
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
+ #
6
+ # This program is free software: you can redistribute it and/or modify
7
+ # it under the terms of the GNU General Public License as published by
8
+ # the Free Software Foundation, either version 3 of the License, or
9
+ # (at your option) any later version.
10
+ #
11
+ # This program is distributed in the hope that it will be useful,
12
+ # but WITHOUT ANY WARRANTY; without even the implied warranty of
13
+ # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
14
+ # GNU General Public License for more details.
15
+ #
16
+ # You should have received a copy of the GNU General Public License
17
+ # along with this program. If not, see <https://www.gnu.org/licenses/>.
18
+
19
+ import time
20
+ import queue
21
+ from enum import Enum, IntEnum
22
+ from typing import Optional, Tuple
23
+ import logging
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
+ RESET = 101 # Resets the display
36
+ CLEAR = 102 # Clears the display to a white screen
37
+ TO_BLACK = 103 # Clears the display to a black screen. Works on Official Turing 3.5, may not work on other models
38
+ SCREEN_OFF = 108 # Turns the screen off
39
+ SCREEN_ON = 109 # Turns the screen on
40
+ SET_BRIGHTNESS = 110 # Sets the screen brightness
41
+ SET_ORIENTATION = 121 # Sets the screen orientation
42
+ DISPLAY_BITMAP = 197 # Displays an image on the screen
43
+
44
+ # Commands below are only supported by next generation Turing Smart screens
45
+ LCD_28 = 40 # ?
46
+ LCD_29 = 41 # ?
47
+ HELLO = 69 # Asks the screen for its model: 3.5", 5" or 7"
48
+ SET_MIRROR = 122 # Mirrors the rendering on the screen
49
+ DISPLAY_PIXELS = 195 # Displays a list of pixels than can be non-contiguous in one command, useful for line charts
50
+
51
+
52
+ class SubRevision(Enum):
53
+ TURING_3_5 = 0 # Official Turing 3.5 do not answer to HELLO command
54
+ USBMONITOR_3_5 = bytearray([0x01, 0x01, 0x01, 0x01, 0x01, 0x01])
55
+ USBMONITOR_5 = bytearray([0x02, 0x02, 0x02, 0x02, 0x02, 0x02])
56
+ USBMONITOR_7 = bytearray([0x03, 0x03, 0x03, 0x03, 0x03, 0x03])
57
+
58
+
59
+ # This class is for Turing Smart Screen (rev. A) 3.5" and UsbMonitor screens (all sizes)
60
+ class LcdCommRevA(LcdComm):
61
+ def __init__(
62
+ self,
63
+ com_port: str = "AUTO",
64
+ display_width: int = 320,
65
+ display_height: int = 480,
66
+ update_queue: Optional[queue.Queue] = None,
67
+ ):
68
+ logger.debug("HW revision: A")
69
+ LcdComm.__init__(self, com_port, display_width, display_height, update_queue)
70
+ self.open_serial()
71
+
72
+ def __del__(self):
73
+ self.close_serial()
74
+
75
+ @staticmethod
76
+ def auto_detect_com_port() -> Optional[str]:
77
+ com_ports = comports()
78
+ auto_com_port = None
79
+
80
+ for com_port in com_ports:
81
+ if com_port.serial_number == "USB35INCHIPSV2":
82
+ auto_com_port = com_port.device
83
+ break
84
+
85
+ return auto_com_port
86
+
87
+ def send_command(
88
+ self, cmd: Command, x: int, y: int, ex: int, ey: int, bypass_queue: bool = False
89
+ ):
90
+ byte_buffer = bytearray(6)
91
+ byte_buffer[0] = x >> 2
92
+ byte_buffer[1] = ((x & 3) << 6) + (y >> 4)
93
+ byte_buffer[2] = ((y & 15) << 4) + (ex >> 6)
94
+ byte_buffer[3] = ((ex & 63) << 2) + (ey >> 8)
95
+ byte_buffer[4] = ey & 255
96
+ byte_buffer[5] = cmd
97
+
98
+ # If no queue for async requests, or if asked explicitly to do the request sequentially: do request now
99
+ if not self.update_queue or bypass_queue:
100
+ self.write_data(byte_buffer)
101
+ else:
102
+ # Lock queue mutex then queue the request
103
+ with self.update_queue_mutex:
104
+ self.update_queue.put((self.write_data, [byte_buffer]))
105
+
106
+ def _hello(self):
107
+ hello = bytearray(
108
+ [
109
+ Command.HELLO,
110
+ Command.HELLO,
111
+ Command.HELLO,
112
+ Command.HELLO,
113
+ Command.HELLO,
114
+ Command.HELLO,
115
+ ]
116
+ )
117
+
118
+ # This command reads LCD answer on serial link, so it bypasses the queue
119
+ self.write_data(hello)
120
+ response = self.serial_read(6)
121
+ self.serial_flush_input()
122
+
123
+ if response == SubRevision.USBMONITOR_3_5.value:
124
+ self.sub_revision = SubRevision.USBMONITOR_3_5
125
+ self.display_width = 320
126
+ self.display_height = 480
127
+ elif response == SubRevision.USBMONITOR_5.value:
128
+ self.sub_revision = SubRevision.USBMONITOR_5
129
+ self.display_width = 480
130
+ self.display_height = 800
131
+ elif response == SubRevision.USBMONITOR_7.value:
132
+ self.sub_revision = SubRevision.USBMONITOR_7
133
+ self.display_width = 600
134
+ self.display_height = 1024
135
+ else:
136
+ self.sub_revision = SubRevision.TURING_3_5
137
+ self.display_width = 320
138
+ self.display_height = 480
139
+
140
+ logger.debug("HW sub-revision: %s" % (str(self.sub_revision)))
141
+
142
+ def initialize_comm(self):
143
+ self._hello()
144
+
145
+ def reset(self):
146
+ logger.info("Display reset (COM port may change)...")
147
+ # Reset command bypasses queue because it is run when queue threads are not yet started
148
+ self.send_command(Command.RESET, 0, 0, 0, 0, bypass_queue=True)
149
+ self.close_serial()
150
+ # Wait for display reset then reconnect
151
+ time.sleep(5)
152
+ self.open_serial()
153
+
154
+ def clear(self):
155
+ self.set_orientation(
156
+ Orientation.PORTRAIT
157
+ ) # Bug: orientation needs to be PORTRAIT before clearing
158
+ if self.sub_revision == SubRevision.TURING_3_5:
159
+ self.send_command(Command.TO_BLACK, 0, 0, 0, 0)
160
+ else:
161
+ self.send_command(Command.CLEAR, 0, 0, 0, 0)
162
+ self.set_orientation() # Restore default orientation
163
+
164
+ def screen_off(self):
165
+ self.send_command(Command.SCREEN_OFF, 0, 0, 0, 0)
166
+
167
+ def screen_on(self):
168
+ self.send_command(Command.SCREEN_ON, 0, 0, 0, 0)
169
+
170
+ def set_brightness(self, level: int = 25):
171
+ assert 0 <= level <= 100, "Brightness level must be [0-100]"
172
+
173
+ # Display scales from 0 to 255, with 0 being the brightest and 255 being the darkest.
174
+ # Convert our brightness % to an absolute value.
175
+ level_absolute = int(255 - ((level / 100) * 255))
176
+
177
+ # Level : 0 (brightest) - 255 (darkest)
178
+ self.send_command(Command.SET_BRIGHTNESS, level_absolute, 0, 0, 0)
179
+
180
+ def set_orientation(self, orientation: Orientation = Orientation.PORTRAIT):
181
+ self.orientation = orientation
182
+ width = self.width()
183
+ height = self.height()
184
+ x = 0
185
+ y = 0
186
+ ex = 0
187
+ ey = 0
188
+ byte_buffer = bytearray(16)
189
+ byte_buffer[0] = x >> 2
190
+ byte_buffer[1] = ((x & 3) << 6) + (y >> 4)
191
+ byte_buffer[2] = ((y & 15) << 4) + (ex >> 6)
192
+ byte_buffer[3] = ((ex & 63) << 2) + (ey >> 8)
193
+ byte_buffer[4] = ey & 255
194
+ byte_buffer[5] = Command.SET_ORIENTATION
195
+ byte_buffer[6] = orientation + 100
196
+ byte_buffer[7] = width >> 8
197
+ byte_buffer[8] = width & 255
198
+ byte_buffer[9] = height >> 8
199
+ byte_buffer[10] = height & 255
200
+ self.serial_write(bytes(byte_buffer))
201
+
202
+ def paint(
203
+ self,
204
+ image: Image.Image,
205
+ pos: Tuple[int, int] = (0, 0),
206
+ ):
207
+ image = self._crop_to_display_bounds(image, pos)
208
+ image_width, image_height = image.size[0], image.size[1]
209
+
210
+ if image_height == 0 or image_width == 0:
211
+ return
212
+
213
+ x, y = pos
214
+ x1, y1 = x + image_width - 1, y + image_height - 1
215
+
216
+ rgb565le = image_to_rgb565(image, "little")
217
+
218
+ self.send_command(Command.DISPLAY_BITMAP, x, y, x1, y1)
219
+
220
+ # Lock queue mutex then queue all the requests for the image data
221
+ with self.update_queue_mutex:
222
+ width = self.width()
223
+ # Send image data by multiple of "display width" bytes
224
+ for chunk in chunked(rgb565le, width * 8):
225
+ self.send_line(chunk)