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,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)
|