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