Skip to content

Commit 3c29039

Browse files
committed
feat(lcd): Add support for Solarmax / HL-VMAX (Artinchip VID 0x33C3 PID 0xF101) displays
- Added LcdCommVmax driver supporting USB CDC serial protocol - Added automatic geometry descriptor parsing (462x1920, 320x1480, 480x1920) - Implemented thread-safe in-memory canvas buffering and frame queue rate-limiting - Configured CDC serial parameters (rtscts=False, dtr=True, rts=True) for reliable handshake - Patched GPUtil and Windows process execution with CREATE_NO_WINDOW - Added unit tests, test script, configure.py model support, and documentation
1 parent 2e060fa commit 3c29039

10 files changed

Lines changed: 460 additions & 13 deletions

File tree

README.md

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -26,9 +26,9 @@ Supported operating systems : macOS, Windows, Linux (incl. Raspberry Pi), basica
2626
| <img src="res/docs/xuanfang.webp"/> | <img src="res/docs/UsbPCMonitor_5inch.webp" width="60%" height="60%"/> | <img src="res/docs/kipye-qiye-35.webp" width="60%" height="60%"/> |
2727
| revision B & flagship (with backplate & RGB LEDs) | Unknown manufacturer, visually similar to Turing 3.5" / 5". Original software is `UsbPCMonitor.exe` | Front panel has an engraved inscription "奇叶智显" Qiye Zhixian (Qiye Smart Display) |
2828

29-
| ✅ WeAct Studio Display FS V1 0.96" | ✅ WeAct Studio Display FS V1 3.5" |
30-
|---------------------------------------------------------------|--------------------------------------------------------------|
31-
| <img src="res/docs/weact_0.96.jpg" width="60%" height="60%"/> | <img src="res/docs/weact_3.5.png" width="60%" height="60%"/> |
29+
| ✅ WeAct Studio Display FS V1 0.96" | ✅ WeAct Studio Display FS V1 3.5" | ✅ Solarmax / HL-VMAX (Artinchip) |
30+
|---------------------------------------------------------------|--------------------------------------------------------------|---------------------------------------------------------------|
31+
| <img src="res/docs/weact_0.96.jpg" width="60%" height="60%"/> | <img src="res/docs/weact_3.5.png" width="60%" height="60%"/> | Ultra-wide PC case & secondary displays (320x1480, 462x1920) |
3232

3333
<details>
3434

config.yaml

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -10,15 +10,15 @@ config:
1010
# Theme to use (located in res/themes)
1111
# Use the name of the folder as value
1212
# Choose a theme made for your screen size (see DISPLAY_SIZE inside theme.yaml)
13-
THEME: 3.5inchTheme2
13+
THEME: "Cyberpunk 2077 Vertical"
1414

1515
# Hardware sensors reading
1616
# Choose the appropriate method for reading your hardware sensors:
1717
# - PYTHON use Python libraries (psutils, GPUtil...) to read hardware sensors (supports all OS but not all HW)
1818
# - LHM use LibreHardwareMonitor library to read hardware sensors (Windows only - NEEDS ADMIN RIGHTS)
1919
# - STUB / STATIC use random/static data instead of real hardware sensors
2020
# - AUTO use the best method based on your OS: Windows OS will use LHM, other OS will use Python libraries
21-
HW_SENSORS: AUTO
21+
HW_SENSORS: PYTHON
2222

2323
# Network interfaces
2424
# Linux/MacOS interfaces are named "eth0", "wlan0", "wlp1s0", "enp2s0"...
@@ -59,14 +59,15 @@ display:
5959
# - TUR_USB for Turing HW revisions 1.x: 4.6"/5.2"/8.0"/8.8"/9.2"
6060
# - WEACT_A for WeAct Studio Display FS V1 3.5"
6161
# - WEACT_B for WeAct Studio Display FS V1 0.96"
62+
# - VMAX for Solarmax / HL-VMAX (Artinchip VID 0x33C3 PID 0xF101)
6263
# - SIMU for simulated display (image written in screencap.png). Width & height will be detected from the theme
6364
# To identify your smart screen: https://github.com/mathoudebine/turing-smart-screen-python/wiki/Hardware-revisions
64-
REVISION: A
65+
REVISION: VMAX
6566

6667
# Display Brightness
6768
# Set this as the desired %, 0 being completely dark and 100 being max brightness
6869
# Warning: revision A display can get hot at high brightness!
69-
BRIGHTNESS: 20
70+
BRIGHTNESS: 80
7071

7172
# Display reverse: true/false
7273
# Set to true to reverse display orientation (landscape <-> reverse landscape, portrait <-> reverse portrait)
@@ -77,4 +78,4 @@ display:
7778
# By default, this program resets the display on startup, in case it is in a degraded state from previous runs.
7879
# This behavior causes the display to disconnect/reconnect, and the COM port to change.
7980
# On some displays (like rev. A) it can be an issue, in this case change the value to "false".
80-
RESET_ON_STARTUP: true
81+
RESET_ON_STARTUP: false

configure.py

100755100644
Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,7 @@
6363
XUANFANG_MODEL = "XuanFang rev. B & flagship"
6464
KIPYE_MODEL = "Kipye Qiye Smart Display"
6565
WEACT_MODEL = "WeAct Studio Display FS V1"
66+
SOLARMAX_MODEL = "Solarmax / HL-VMAX (Artinchip)"
6667
SIMULATED_MODEL = "Simulated screen"
6768

6869
_SIZE_2_1_INCH = "2.1\"" # Only for retro compatibility
@@ -77,6 +78,7 @@
7778
SIZE_8_INCH = "8\""
7879
SIZE_8_8_INCH = "8.8\""
7980
SIZE_8_8_INCH_NEWREV = "8.8\" / 9.2\" (V1.X new HW rev.)"
81+
SIZE_11_3_INCH = "11.3\" / 9.2\" (320x1480 / 462x1920)"
8082
SIZE_12_3_INCH = "12.3\""
8183
SIZE_2_8_INCH_NEWREV = "2.8\" round (V1.X new HW rev.)"
8284

@@ -92,6 +94,7 @@
9294
SIZE_8_INCH,
9395
SIZE_8_8_INCH,
9496
SIZE_8_8_INCH_NEWREV,
97+
SIZE_11_3_INCH,
9598
SIZE_12_3_INCH,
9699
)
97100

@@ -114,6 +117,9 @@
114117
('TUR_USB', SIZE_2_8_INCH_NEWREV): TURING_MODEL,
115118
('WEACT_A', SIZE_3_5_INCH): WEACT_MODEL,
116119
('WEACT_B', SIZE_0_96_INCH): WEACT_MODEL,
120+
('VMAX', SIZE_11_3_INCH): SOLARMAX_MODEL,
121+
('VMAX', SIZE_8_8_INCH): SOLARMAX_MODEL,
122+
('VMAX', SIZE_8_8_INCH_NEWREV): SOLARMAX_MODEL,
117123

118124
('SIMU', SIZE_0_96_INCH): SIMULATED_MODEL,
119125
('SIMU', SIZE_2_x_INCH): SIMULATED_MODEL,
@@ -123,10 +129,14 @@
123129
('SIMU', SIZE_5_2_INCH): SIMULATED_MODEL,
124130
('SIMU', SIZE_8_INCH): SIMULATED_MODEL,
125131
('SIMU', SIZE_8_8_INCH): SIMULATED_MODEL,
132+
('SIMU', SIZE_11_3_INCH): SIMULATED_MODEL,
126133
}
127134
# This map is used to write the correct config.yaml "REVISION" from selected smart screen model and size
128135
model_and_size_to_revision_map = {
129136
(KIPYE_MODEL, SIZE_3_5_INCH): 'D',
137+
(SOLARMAX_MODEL, SIZE_11_3_INCH): 'VMAX',
138+
(SOLARMAX_MODEL, SIZE_8_8_INCH): 'VMAX',
139+
(SOLARMAX_MODEL, SIZE_8_8_INCH_NEWREV): 'VMAX',
130140
(TURING_MODEL, SIZE_2_x_INCH): 'C',
131141
(TURING_MODEL, SIZE_3_5_INCH): 'A',
132142
(TURING_MODEL, SIZE_4_6_INCH): 'TUR_USB',
@@ -151,6 +161,7 @@
151161
(SIMULATED_MODEL, SIZE_5_2_INCH): 'SIMU',
152162
(SIMULATED_MODEL, SIZE_8_INCH): 'SIMU',
153163
(SIMULATED_MODEL, SIZE_8_8_INCH): 'SIMU',
164+
(SIMULATED_MODEL, SIZE_11_3_INCH): 'SIMU',
154165
}
155166
hw_lib_map = {"AUTO": "Automatic", "LHM": "LibreHardwareMonitor (admin.)", "PYTHON": "Python libraries",
156167
"STUB": "Fake random data", "STATIC": "Fake static data"}
@@ -606,7 +617,7 @@ def on_theme_editor_click(self):
606617
# Load binary (for releases) or Python file with system interpreter
607618
theme_editor = next(MAIN_DIRECTORY.glob("theme-editor*"))
608619
if platform.system() == "Windows":
609-
subprocess.Popen([str(theme_editor), self.theme_cb.get()], shell=True)
620+
subprocess.Popen([str(theme_editor), self.theme_cb.get()], creationflags=0x08000000)
610621
else:
611622
subprocess.Popen([str(theme_editor), self.theme_cb.get()])
612623

@@ -624,7 +635,7 @@ def on_saverun_click(self):
624635
# Load binary (for releases) or Python file with system interpreter
625636
main_file = next(MAIN_DIRECTORY.glob("main*"))
626637
if platform.system() == "Windows":
627-
subprocess.Popen([str(main_file)], shell=True)
638+
subprocess.Popen([str(main_file)], creationflags=0x08000000)
628639
else:
629640
subprocess.Popen([str(main_file)])
630641

library/display.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@
2727
from library.lcd.lcd_comm_rev_d import LcdCommRevD
2828
from library.lcd.lcd_comm_weact_a import LcdCommWeActA
2929
from library.lcd.lcd_comm_weact_b import LcdCommWeActB
30+
from library.lcd.lcd_comm_vmax import LcdCommVmax
3031
from library.lcd.lcd_simulated import LcdSimulated
3132
from library.log import logger
3233

@@ -76,6 +77,8 @@ def _get_theme_size() -> tuple[int, int]:
7677
return 480, 1920
7778
elif config.THEME_DATA["display"].get("DISPLAY_SIZE", '') == '9.2"':
7879
return 480, 1920 # 9.2" displays are 1920x462 but using 1920x480 to be compatible with 8.8" themes
80+
elif config.THEME_DATA["display"].get("DISPLAY_SIZE", '') == '11.3"':
81+
return 320, 1480
7982
elif config.THEME_DATA["display"].get("DISPLAY_SIZE", '') == '12.3"':
8083
return 720, 1920
8184
else:
@@ -109,6 +112,9 @@ def __init__(self):
109112
elif config.CONFIG_DATA["display"]["REVISION"] == "WEACT_B":
110113
self.lcd = LcdCommWeActB(com_port=config.CONFIG_DATA['config']['COM_PORT'],
111114
update_queue=config.update_queue)
115+
elif config.CONFIG_DATA["display"]["REVISION"] in ("VMAX", "SOLARMAX"):
116+
self.lcd = LcdCommVmax(com_port=config.CONFIG_DATA['config']['COM_PORT'],
117+
update_queue=config.update_queue, display_width=width, display_height=height)
112118
elif config.CONFIG_DATA["display"]["REVISION"] == "SIMU":
113119
# Simulated display: always set width/height from theme
114120
self.lcd = LcdSimulated(display_width=width, display_height=height)

library/lcd/lcd_comm_vmax.py

Lines changed: 233 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,233 @@
1+
# SPDX-License-Identifier: GPL-3.0-or-later
2+
#
3+
# turing-smart-screen-python - a Python system monitor and library for USB-C displays like Turing Smart Screen or XuanFang
4+
# https://github.com/mathoudebine/turing-smart-screen-python/
5+
#
6+
# Driver for HL-VMAX / Solarmax USB LCD Displays (Artinchip VID 0x33C3, PID 0xF101)
7+
# Supports 462x1920, 320x1480, 480x1920 resolutions.
8+
#
9+
10+
import io
11+
import re
12+
import time
13+
import threading
14+
from typing import Optional, Tuple
15+
from enum import IntEnum
16+
import serial
17+
from serial.tools.list_ports import comports
18+
from PIL import Image
19+
20+
from library.lcd.lcd_comm import LcdComm, Orientation, queue
21+
from library.log import logger
22+
23+
VID = 0x33C3
24+
PID = 0xF101
25+
HANDSHAKE = bytes([0xF0, 0xA5, 0x5A, 0x0F])
26+
SN_LEN = 26
27+
DEFAULT_WIDTH = 462
28+
DEFAULT_HEIGHT = 1920
29+
30+
31+
class LcdCommVmax(LcdComm):
32+
def __init__(self, com_port: str = "AUTO", display_width: int = DEFAULT_WIDTH,
33+
display_height: int = DEFAULT_HEIGHT, update_queue: Optional[queue.Queue] = None):
34+
logger.debug("HW revision: VMAX / Solarmax (Artinchip)")
35+
super().__init__(com_port, display_width, display_height, update_queue)
36+
self.serial_number = None
37+
self.brightness = 100
38+
39+
# Canvas for full frame composition
40+
self._canvas_lock = threading.Lock()
41+
self._canvas = Image.new("RGB", (self.display_width, self.display_height), (0, 0, 0))
42+
self._frame_pending = False
43+
self._serial_lock = threading.Lock()
44+
45+
self.openSerial()
46+
47+
def __del__(self):
48+
self.closeSerial()
49+
50+
@staticmethod
51+
def auto_detect_com_port() -> Optional[str]:
52+
ports = comports()
53+
for p in ports:
54+
if p.vid == VID and p.pid == PID:
55+
return p.device
56+
if p.serial_number and "VMAX" in p.serial_number.upper():
57+
return p.device
58+
return None
59+
60+
def openSerial(self):
61+
with self._serial_lock:
62+
for attempt in range(1, 11):
63+
com_port = self.com_port
64+
if com_port == 'AUTO':
65+
com_port = self.auto_detect_com_port()
66+
if not com_port:
67+
logger.warning(f"Cannot find HL-VMAX COM port automatically, retrying ({attempt}/10)")
68+
time.sleep(1)
69+
continue
70+
logger.debug(f"Auto detected COM port: {com_port}")
71+
else:
72+
logger.debug(f"Static COM port: {com_port}")
73+
74+
try:
75+
self.lcd_serial = serial.Serial(
76+
com_port,
77+
115200,
78+
timeout=2.0,
79+
write_timeout=2.0,
80+
rtscts=False,
81+
dsrdtr=False
82+
)
83+
self.lcd_serial.dtr = True
84+
self.lcd_serial.rts = True
85+
self.lcd_serial.reset_input_buffer()
86+
self.lcd_serial.reset_output_buffer()
87+
return
88+
except Exception as e:
89+
logger.warning(f"Cannot open COM port {com_port}: {e} - retrying ({attempt}/10)")
90+
time.sleep(1)
91+
92+
def InitializeComm(self):
93+
with self._serial_lock:
94+
logger.debug("Initializing HL-VMAX communication...")
95+
self.serial_flush_input()
96+
try:
97+
self.serial_write(HANDSHAKE)
98+
raw = self.serial_read(SN_LEN)
99+
if len(raw) == SN_LEN:
100+
self.serial_number = raw.decode("ascii", errors="replace").strip()
101+
logger.info(f"Connected to HL-VMAX screen, Serial Number: {self.serial_number}")
102+
self._parse_geometry(self.serial_number)
103+
else:
104+
logger.warning(f"Unexpected handshake response length: {len(raw)} bytes")
105+
except Exception as e:
106+
logger.error(f"Failed during HL-VMAX handshake: {e}")
107+
108+
def _parse_geometry(self, sn: str):
109+
# Format example: VMAXD160462*1920S262001750 or VMAXA170320*1480S261001155
110+
m = re.search(r"(\d{3,4})\s*\*\s*(\d{2,5})", sn)
111+
if m:
112+
w, h = int(m.group(1)), int(m.group(2))
113+
if w >= 100 and h >= 100:
114+
logger.info(f"Detected hardware panel geometry: {w}x{h}")
115+
self.display_width = w
116+
self.display_height = h
117+
with self._canvas_lock:
118+
self._canvas = Image.new("RGB", (self.display_width, self.display_height), (0, 0, 0))
119+
120+
def Reset(self):
121+
with self._serial_lock:
122+
self.serial_flush_input()
123+
self.Clear()
124+
125+
def Clear(self):
126+
with self._canvas_lock:
127+
self._canvas = Image.new("RGB", (self.display_width, self.display_height), (0, 0, 0))
128+
self._enqueue_render()
129+
130+
def ScreenOff(self):
131+
self.SetBrightness(0)
132+
133+
def ScreenOn(self):
134+
self.SetBrightness(self.brightness if self.brightness > 0 else 100)
135+
136+
def SetBrightness(self, level: int):
137+
self.brightness = max(0, min(100, int(level)))
138+
cmd = bytes([0xAA, 0xBB, self.brightness, 0xCC, 0xDD])
139+
if self.update_queue:
140+
with self.update_queue_mutex:
141+
self.update_queue.put((self._send_brightness_direct, [cmd]))
142+
else:
143+
self._send_brightness_direct(cmd)
144+
145+
def _send_brightness_direct(self, cmd: bytes):
146+
with self._serial_lock:
147+
try:
148+
if self.lcd_serial and self.lcd_serial.is_open:
149+
self.serial_write(cmd)
150+
except Exception as e:
151+
logger.warning(f"Failed to send brightness command: {e}")
152+
153+
def SetOrientation(self, orientation: Orientation):
154+
self.orientation = orientation
155+
with self._canvas_lock:
156+
target_w = self.get_width()
157+
target_h = self.get_height()
158+
self._canvas = Image.new("RGB", (target_w, target_h), (0, 0, 0))
159+
160+
def DisplayPILImage(
161+
self,
162+
image: Image.Image,
163+
x: int = 0, y: int = 0,
164+
image_width: int = 0,
165+
image_height: int = 0
166+
):
167+
if image_width != 0 and image_height != 0 and (image.size[0] != image_width or image.size[1] != image_height):
168+
image = image.resize((image_width, image_height))
169+
170+
target_w = self.get_width()
171+
target_h = self.get_height()
172+
173+
with self._canvas_lock:
174+
if self._canvas.size != (target_w, target_h):
175+
self._canvas = Image.new("RGB", (target_w, target_h), (0, 0, 0))
176+
177+
if x == 0 and y == 0 and image.size == (target_w, target_h):
178+
self._canvas.paste(image, (0, 0))
179+
else:
180+
self._canvas.paste(image, (x, y))
181+
182+
self._enqueue_render()
183+
184+
def _enqueue_render(self):
185+
if self.update_queue:
186+
with self.update_queue_mutex:
187+
if not self._frame_pending:
188+
self._frame_pending = True
189+
self.update_queue.put((self._render_and_send_frame, []))
190+
else:
191+
self._render_and_send_frame()
192+
193+
def _render_and_send_frame(self):
194+
with self.update_queue_mutex:
195+
self._frame_pending = False
196+
197+
with self._canvas_lock:
198+
frame_to_send = self._orient_image(self._canvas.copy())
199+
200+
if frame_to_send.mode != "RGB":
201+
frame_to_send = frame_to_send.convert("RGB")
202+
203+
buf = io.BytesIO()
204+
frame_to_send.save(buf, format="JPEG", quality=88)
205+
jpeg_bytes = buf.getvalue()
206+
207+
with self._serial_lock:
208+
try:
209+
if self.lcd_serial and self.lcd_serial.is_open:
210+
self.serial_write(jpeg_bytes)
211+
self.lcd_serial.flush()
212+
except Exception as e:
213+
logger.warning(f"Failed to send frame to HL-VMAX: {e}")
214+
215+
def _orient_image(self, img: Image.Image) -> Image.Image:
216+
"""Transforms logical image to match physical portrait panel (width x height)."""
217+
if self.orientation == Orientation.PORTRAIT:
218+
if img.size != (self.display_width, self.display_height):
219+
img = img.resize((self.display_width, self.display_height))
220+
return img
221+
elif self.orientation == Orientation.REVERSE_PORTRAIT:
222+
if img.size != (self.display_width, self.display_height):
223+
img = img.resize((self.display_width, self.display_height))
224+
return img.rotate(180)
225+
elif self.orientation == Orientation.LANDSCAPE:
226+
if img.size != (self.display_height, self.display_width):
227+
img = img.resize((self.display_height, self.display_width))
228+
return img.rotate(90, expand=True)
229+
elif self.orientation == Orientation.REVERSE_LANDSCAPE:
230+
if img.size != (self.display_height, self.display_width):
231+
img = img.resize((self.display_height, self.display_width))
232+
return img.rotate(270, expand=True)
233+
return img

0 commit comments

Comments
 (0)