diff --git a/README.md b/README.md index d8a8891d6..2a19fc300 100644 --- a/README.md +++ b/README.md @@ -26,9 +26,9 @@ Supported operating systems : macOS, Windows, Linux (incl. Raspberry Pi), basica | | | | | 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) | -| ✅ WeAct Studio Display FS V1 0.96" | ✅ WeAct Studio Display FS V1 3.5" | -|---------------------------------------------------------------|--------------------------------------------------------------| -| | | +| ✅ WeAct Studio Display FS V1 0.96" | ✅ WeAct Studio Display FS V1 3.5" | ✅ Solarmax / HL-VMAX (Artinchip) | +|---------------------------------------------------------------|--------------------------------------------------------------|---------------------------------------------------------------| +| | | Ultra-wide PC case & secondary displays (320x1480, 462x1920) |
diff --git a/config.yaml b/config.yaml index 8aef493e4..fd101ad87 100644 --- a/config.yaml +++ b/config.yaml @@ -10,7 +10,7 @@ config: # Theme to use (located in res/themes) # Use the name of the folder as value # Choose a theme made for your screen size (see DISPLAY_SIZE inside theme.yaml) - THEME: 3.5inchTheme2 + THEME: "Cyberpunk 2077 Vertical" # Hardware sensors reading # Choose the appropriate method for reading your hardware sensors: @@ -18,7 +18,7 @@ config: # - LHM use LibreHardwareMonitor library to read hardware sensors (Windows only - NEEDS ADMIN RIGHTS) # - STUB / STATIC use random/static data instead of real hardware sensors # - AUTO use the best method based on your OS: Windows OS will use LHM, other OS will use Python libraries - HW_SENSORS: AUTO + HW_SENSORS: PYTHON # Network interfaces # Linux/MacOS interfaces are named "eth0", "wlan0", "wlp1s0", "enp2s0"... @@ -59,14 +59,15 @@ display: # - TUR_USB for Turing HW revisions 1.x: 4.6"/5.2"/8.0"/8.8"/9.2" # - WEACT_A for WeAct Studio Display FS V1 3.5" # - WEACT_B for WeAct Studio Display FS V1 0.96" + # - VMAX for Solarmax / HL-VMAX (Artinchip VID 0x33C3 PID 0xF101) # - SIMU for simulated display (image written in screencap.png). Width & height will be detected from the theme # To identify your smart screen: https://github.com/mathoudebine/turing-smart-screen-python/wiki/Hardware-revisions - REVISION: A + REVISION: VMAX # Display Brightness # Set this as the desired %, 0 being completely dark and 100 being max brightness # Warning: revision A display can get hot at high brightness! - BRIGHTNESS: 20 + BRIGHTNESS: 80 # Display reverse: true/false # Set to true to reverse display orientation (landscape <-> reverse landscape, portrait <-> reverse portrait) @@ -77,4 +78,4 @@ display: # By default, this program resets the display on startup, in case it is in a degraded state from previous runs. # This behavior causes the display to disconnect/reconnect, and the COM port to change. # On some displays (like rev. A) it can be an issue, in this case change the value to "false". - RESET_ON_STARTUP: true + RESET_ON_STARTUP: false diff --git a/configure.py b/configure.py old mode 100755 new mode 100644 index f4689f723..1032ff972 --- a/configure.py +++ b/configure.py @@ -63,6 +63,7 @@ XUANFANG_MODEL = "XuanFang rev. B & flagship" KIPYE_MODEL = "Kipye Qiye Smart Display" WEACT_MODEL = "WeAct Studio Display FS V1" +SOLARMAX_MODEL = "Solarmax / HL-VMAX (Artinchip)" SIMULATED_MODEL = "Simulated screen" _SIZE_2_1_INCH = "2.1\"" # Only for retro compatibility @@ -77,6 +78,7 @@ SIZE_8_INCH = "8\"" SIZE_8_8_INCH = "8.8\"" SIZE_8_8_INCH_NEWREV = "8.8\" / 9.2\" (V1.X new HW rev.)" +SIZE_11_3_INCH = "11.3\" / 9.2\" (320x1480 / 462x1920)" SIZE_12_3_INCH = "12.3\"" SIZE_2_8_INCH_NEWREV = "2.8\" round (V1.X new HW rev.)" @@ -92,6 +94,7 @@ SIZE_8_INCH, SIZE_8_8_INCH, SIZE_8_8_INCH_NEWREV, + SIZE_11_3_INCH, SIZE_12_3_INCH, ) @@ -114,6 +117,9 @@ ('TUR_USB', SIZE_2_8_INCH_NEWREV): TURING_MODEL, ('WEACT_A', SIZE_3_5_INCH): WEACT_MODEL, ('WEACT_B', SIZE_0_96_INCH): WEACT_MODEL, + ('VMAX', SIZE_11_3_INCH): SOLARMAX_MODEL, + ('VMAX', SIZE_8_8_INCH): SOLARMAX_MODEL, + ('VMAX', SIZE_8_8_INCH_NEWREV): SOLARMAX_MODEL, ('SIMU', SIZE_0_96_INCH): SIMULATED_MODEL, ('SIMU', SIZE_2_x_INCH): SIMULATED_MODEL, @@ -123,10 +129,14 @@ ('SIMU', SIZE_5_2_INCH): SIMULATED_MODEL, ('SIMU', SIZE_8_INCH): SIMULATED_MODEL, ('SIMU', SIZE_8_8_INCH): SIMULATED_MODEL, + ('SIMU', SIZE_11_3_INCH): SIMULATED_MODEL, } # This map is used to write the correct config.yaml "REVISION" from selected smart screen model and size model_and_size_to_revision_map = { (KIPYE_MODEL, SIZE_3_5_INCH): 'D', + (SOLARMAX_MODEL, SIZE_11_3_INCH): 'VMAX', + (SOLARMAX_MODEL, SIZE_8_8_INCH): 'VMAX', + (SOLARMAX_MODEL, SIZE_8_8_INCH_NEWREV): 'VMAX', (TURING_MODEL, SIZE_2_x_INCH): 'C', (TURING_MODEL, SIZE_3_5_INCH): 'A', (TURING_MODEL, SIZE_4_6_INCH): 'TUR_USB', @@ -151,6 +161,7 @@ (SIMULATED_MODEL, SIZE_5_2_INCH): 'SIMU', (SIMULATED_MODEL, SIZE_8_INCH): 'SIMU', (SIMULATED_MODEL, SIZE_8_8_INCH): 'SIMU', + (SIMULATED_MODEL, SIZE_11_3_INCH): 'SIMU', } hw_lib_map = {"AUTO": "Automatic", "LHM": "LibreHardwareMonitor (admin.)", "PYTHON": "Python libraries", "STUB": "Fake random data", "STATIC": "Fake static data"} @@ -606,7 +617,7 @@ def on_theme_editor_click(self): # Load binary (for releases) or Python file with system interpreter theme_editor = next(MAIN_DIRECTORY.glob("theme-editor*")) if platform.system() == "Windows": - subprocess.Popen([str(theme_editor), self.theme_cb.get()], shell=True) + subprocess.Popen([str(theme_editor), self.theme_cb.get()], creationflags=0x08000000) else: subprocess.Popen([str(theme_editor), self.theme_cb.get()]) @@ -624,7 +635,7 @@ def on_saverun_click(self): # Load binary (for releases) or Python file with system interpreter main_file = next(MAIN_DIRECTORY.glob("main*")) if platform.system() == "Windows": - subprocess.Popen([str(main_file)], shell=True) + subprocess.Popen([str(main_file)], creationflags=0x08000000) else: subprocess.Popen([str(main_file)]) diff --git a/library/display.py b/library/display.py index dabbca52c..bb315ab87 100644 --- a/library/display.py +++ b/library/display.py @@ -27,6 +27,7 @@ from library.lcd.lcd_comm_rev_d import LcdCommRevD from library.lcd.lcd_comm_weact_a import LcdCommWeActA from library.lcd.lcd_comm_weact_b import LcdCommWeActB +from library.lcd.lcd_comm_vmax import LcdCommVmax from library.lcd.lcd_simulated import LcdSimulated from library.log import logger @@ -76,6 +77,8 @@ def _get_theme_size() -> tuple[int, int]: return 480, 1920 elif config.THEME_DATA["display"].get("DISPLAY_SIZE", '') == '9.2"': return 480, 1920 # 9.2" displays are 1920x462 but using 1920x480 to be compatible with 8.8" themes + elif config.THEME_DATA["display"].get("DISPLAY_SIZE", '') == '11.3"': + return 320, 1480 elif config.THEME_DATA["display"].get("DISPLAY_SIZE", '') == '12.3"': return 720, 1920 else: @@ -109,6 +112,9 @@ def __init__(self): elif config.CONFIG_DATA["display"]["REVISION"] == "WEACT_B": self.lcd = LcdCommWeActB(com_port=config.CONFIG_DATA['config']['COM_PORT'], update_queue=config.update_queue) + elif config.CONFIG_DATA["display"]["REVISION"] in ("VMAX", "SOLARMAX"): + self.lcd = LcdCommVmax(com_port=config.CONFIG_DATA['config']['COM_PORT'], + update_queue=config.update_queue, display_width=width, display_height=height) elif config.CONFIG_DATA["display"]["REVISION"] == "SIMU": # Simulated display: always set width/height from theme self.lcd = LcdSimulated(display_width=width, display_height=height) diff --git a/library/lcd/lcd_comm_vmax.py b/library/lcd/lcd_comm_vmax.py new file mode 100644 index 000000000..0aebae65b --- /dev/null +++ b/library/lcd/lcd_comm_vmax.py @@ -0,0 +1,247 @@ +# SPDX-License-Identifier: GPL-3.0-or-later +# +# turing-smart-screen-python - a Python system monitor and library for USB-C displays like Turing Smart Screen or XuanFang +# https://github.com/mathoudebine/turing-smart-screen-python/ +# +# Driver for HL-VMAX / Solarmax USB LCD Displays (Artinchip VID 0x33C3, PID 0xF101) +# Supports 462x1920, 320x1480, 480x1920 resolutions. +# + +import io +import re +import time +import threading +from typing import Optional, Tuple +from enum import IntEnum +import serial +from serial.tools.list_ports import comports +from PIL import Image + +from library.lcd.lcd_comm import LcdComm, Orientation, queue +from library.log import logger + +VID = 0x33C3 +PID = 0xF101 +HANDSHAKE = bytes([0xF0, 0xA5, 0x5A, 0x0F]) +SN_LEN = 26 +DEFAULT_WIDTH = 462 +DEFAULT_HEIGHT = 1920 + + +class LcdCommVmax(LcdComm): + def __init__(self, com_port: str = "AUTO", display_width: int = DEFAULT_WIDTH, + display_height: int = DEFAULT_HEIGHT, update_queue: Optional[queue.Queue] = None): + logger.debug("HW revision: VMAX / Solarmax (Artinchip)") + super().__init__(com_port, display_width, display_height, update_queue) + self.serial_number = None + self.brightness = 100 + + # Canvas for full frame composition + self._canvas_lock = threading.Lock() + self._canvas = Image.new("RGB", (self.display_width, self.display_height), (0, 0, 0)) + self._frame_pending = False + self._serial_lock = threading.Lock() + + self.openSerial() + + def __del__(self): + self.closeSerial() + + @staticmethod + def auto_detect_com_port() -> Optional[str]: + ports = comports() + for p in ports: + if p.vid == VID and p.pid == PID: + return p.device + if p.serial_number and "VMAX" in p.serial_number.upper(): + return p.device + return None + + def openSerial(self): + with self._serial_lock: + for attempt in range(1, 11): + com_port = self.com_port + if com_port == 'AUTO': + com_port = self.auto_detect_com_port() + if not com_port: + logger.warning(f"Cannot find HL-VMAX COM port automatically, retrying ({attempt}/10)") + time.sleep(1) + continue + logger.debug(f"Auto detected COM port: {com_port}") + else: + logger.debug(f"Static COM port: {com_port}") + + try: + self.lcd_serial = serial.Serial( + com_port, + 115200, + timeout=1.0, + write_timeout=1.0, + rtscts=False, + dsrdtr=False + ) + time.sleep(0.05) + try: + self.lcd_serial.reset_input_buffer() + self.lcd_serial.reset_output_buffer() + except Exception: + pass + return + except Exception as e: + logger.warning(f"Cannot open COM port {com_port}: {e} - retrying ({attempt}/10)") + time.sleep(1) + + def InitializeComm(self): + with self._serial_lock: + logger.debug("Initializing HL-VMAX communication...") + try: + if self.lcd_serial and self.lcd_serial.is_open: + try: + self.lcd_serial.reset_input_buffer() + except Exception: + pass + self.lcd_serial.write(HANDSHAKE) + raw = self.lcd_serial.read(SN_LEN) + if len(raw) == SN_LEN: + self.serial_number = raw.decode("ascii", errors="replace").strip() + logger.info(f"Connected to HL-VMAX screen, Serial Number: {self.serial_number}") + self._parse_geometry(self.serial_number) + else: + logger.debug(f"Handshake response length: {len(raw)} bytes") + except Exception as e: + logger.warning(f"HL-VMAX handshake info: {e}") + + def _parse_geometry(self, sn: str): + # Format example: VMAXD160462*1920S262001750 or VMAXA170320*1480S261001155 + m = re.search(r"(\d{3,4})\s*\*\s*(\d{2,5})", sn) + if m: + w, h = int(m.group(1)), int(m.group(2)) + if w >= 100 and h >= 100: + logger.info(f"Detected hardware panel geometry: {w}x{h}") + self.display_width = w + self.display_height = h + with self._canvas_lock: + target_w = self.get_width() + target_h = self.get_height() + self._canvas = Image.new("RGB", (target_w, target_h), (0, 0, 0)) + + def Reset(self): + with self._serial_lock: + if self.lcd_serial and self.lcd_serial.is_open: + try: + self.lcd_serial.reset_input_buffer() + except Exception: + pass + self.Clear() + + def Clear(self): + with self._canvas_lock: + target_w = self.get_width() + target_h = self.get_height() + self._canvas = Image.new("RGB", (target_w, target_h), (0, 0, 0)) + self._enqueue_render() + + def ScreenOff(self): + self.SetBrightness(0) + + def ScreenOn(self): + self.SetBrightness(self.brightness if self.brightness > 0 else 100) + + def SetBrightness(self, level: int): + self.brightness = max(0, min(100, int(level))) + cmd = bytes([0xAA, 0xBB, self.brightness, 0xCC, 0xDD]) + if self.update_queue: + with self.update_queue_mutex: + self.update_queue.put((self._send_brightness_direct, [cmd])) + else: + self._send_brightness_direct(cmd) + + def _send_brightness_direct(self, cmd: bytes): + with self._serial_lock: + try: + if self.lcd_serial and self.lcd_serial.is_open: + self.lcd_serial.write(cmd) + except Exception as e: + logger.warning(f"Failed to send brightness command: {e}") + + def SetOrientation(self, orientation: Orientation): + self.orientation = orientation + with self._canvas_lock: + target_w = self.get_width() + target_h = self.get_height() + self._canvas = Image.new("RGB", (target_w, target_h), (0, 0, 0)) + + def DisplayPILImage( + self, + image: Image.Image, + x: int = 0, y: int = 0, + image_width: int = 0, + image_height: int = 0 + ): + if image_width != 0 and image_height != 0 and (image.size[0] != image_width or image.size[1] != image_height): + image = image.resize((image_width, image_height)) + + target_w = self.get_width() + target_h = self.get_height() + + with self._canvas_lock: + if self._canvas.size != (target_w, target_h): + self._canvas = Image.new("RGB", (target_w, target_h), (0, 0, 0)) + + if x == 0 and y == 0 and image.size == (target_w, target_h): + self._canvas.paste(image, (0, 0)) + else: + self._canvas.paste(image, (x, y)) + + self._enqueue_render() + + def _enqueue_render(self): + if self.update_queue: + with self.update_queue_mutex: + if not self._frame_pending: + self._frame_pending = True + self.update_queue.put((self._render_and_send_frame, [])) + else: + self._render_and_send_frame() + + def _render_and_send_frame(self): + with self.update_queue_mutex: + self._frame_pending = False + + with self._canvas_lock: + frame_to_send = self._orient_image(self._canvas.copy()) + + if frame_to_send.mode != "RGB": + frame_to_send = frame_to_send.convert("RGB") + + buf = io.BytesIO() + frame_to_send.save(buf, format="JPEG", quality=88) + jpeg_bytes = buf.getvalue() + + with self._serial_lock: + try: + if self.lcd_serial and self.lcd_serial.is_open: + self.lcd_serial.write(jpeg_bytes) + self.lcd_serial.flush() + except Exception as e: + logger.warning(f"Failed to send frame to HL-VMAX: {e}") + + def _orient_image(self, img: Image.Image) -> Image.Image: + """Transforms logical image to match physical portrait panel (width x height).""" + if self.orientation == Orientation.PORTRAIT: + if img.size != (self.display_width, self.display_height): + img = img.resize((self.display_width, self.display_height)) + return img + elif self.orientation == Orientation.REVERSE_PORTRAIT: + if img.size != (self.display_width, self.display_height): + img = img.resize((self.display_width, self.display_height)) + return img.rotate(180) + elif self.orientation == Orientation.LANDSCAPE: + if img.size != (self.display_height, self.display_width): + img = img.resize((self.display_height, self.display_width)) + return img.rotate(270, expand=True) + elif self.orientation == Orientation.REVERSE_LANDSCAPE: + if img.size != (self.display_height, self.display_width): + img = img.resize((self.display_height, self.display_width)) + return img.rotate(90, expand=True) + return img diff --git a/library/sensors/sensors_librehardwaremonitor.py b/library/sensors/sensors_librehardwaremonitor.py index 473059c75..7d347576a 100644 --- a/library/sensors/sensors_librehardwaremonitor.py +++ b/library/sensors/sensors_librehardwaremonitor.py @@ -36,13 +36,21 @@ import library.sensors.sensors as sensors from library.log import logger +from pathlib import Path # Import LibreHardwareMonitor dll to Python -lhm_dll = os.getcwd() + '\\external\\LibreHardwareMonitor\\LibreHardwareMonitorLib.dll' +base_dir = Path(getattr(sys, '_MEIPASS', Path(__file__).resolve().parents[2])) +lhm_dir = base_dir / "external" / "LibreHardwareMonitor" +if not (lhm_dir / "LibreHardwareMonitorLib.dll").exists(): + lhm_dir = Path(os.getcwd()) / "external" / "LibreHardwareMonitor" + +lhm_dll = str(lhm_dir / "LibreHardwareMonitorLib.dll") +hidsharp_dll = str(lhm_dir / "HidSharp.dll") + # noinspection PyUnresolvedReferences clr.AddReference(lhm_dll) # noinspection PyUnresolvedReferences -clr.AddReference(os.getcwd() + '\\external\\LibreHardwareMonitor\\HidSharp.dll') +clr.AddReference(hidsharp_dll) # noinspection PyUnresolvedReferences from LibreHardwareMonitor import Hardware @@ -55,25 +63,33 @@ str(HIWORD(ls_file_version)), str(LOWORD(ls_file_version))])) -if ctypes.windll.shell32.IsUserAnAdmin() == 0: - logger.error( - "Program is not running as administrator. Please run with admin rights or choose another HW_SENSORS option in " - "config.yaml") - try: - sys.exit(0) - except: - os._exit(0) +is_admin = ctypes.windll.shell32.IsUserAnAdmin() != 0 +if not is_admin: + logger.warning( + "Program is not running as administrator. Motherboard fan/temp sensors may be limited. " + "Run as administrator for full hardware sensor access.") handle = Hardware.Computer() handle.IsCpuEnabled = True handle.IsGpuEnabled = True handle.IsMemoryEnabled = True -handle.IsMotherboardEnabled = True # For CPU Fan Speed -handle.IsControllerEnabled = True # For CPU Fan Speed +handle.IsMotherboardEnabled = is_admin # Motherboard ring0 driver needs admin rights +handle.IsControllerEnabled = is_admin # Fan controller needs admin rights handle.IsNetworkEnabled = True handle.IsStorageEnabled = True handle.IsPsuEnabled = False -handle.Open() +try: + handle.Open() +except Exception as e: + logger.warning(f"Error opening LHM hardware computer: {e}") + # Retry without motherboard/controller if failed + if handle.IsMotherboardEnabled or handle.IsControllerEnabled: + handle.IsMotherboardEnabled = False + handle.IsControllerEnabled = False + try: + handle.Open() + except Exception: + pass for hardware in handle.Hardware: if hardware.HardwareType == Hardware.HardwareType.Cpu: logger.info("Found CPU: %s" % hardware.Name) diff --git a/library/sensors/sensors_python.py b/library/sensors/sensors_python.py index fc76af1bd..2a8203ed2 100644 --- a/library/sensors/sensors_python.py +++ b/library/sensors/sensors_python.py @@ -30,6 +30,60 @@ # Nvidia GPU import GPUtil + +if platform.system() == "Windows": + import subprocess + import shutil + import os + + def _no_window_getGPUs(): + nvidia_smi = shutil.which('nvidia-smi') + if nvidia_smi is None: + nvidia_smi = "%s\\Program Files\\NVIDIA Corporation\\NVSMI\\nvidia-smi.exe" % os.environ.get('systemdrive', 'C:') + + creationflags = 0x08000000 # CREATE_NO_WINDOW + startupinfo = subprocess.STARTUPINFO() + startupinfo.dwFlags |= subprocess.STARTF_USESHOWWINDOW + startupinfo.wShowWindow = subprocess.SW_HIDE + + try: + p = subprocess.Popen( + [nvidia_smi, "--query-gpu=index,uuid,utilization.gpu,memory.total,memory.used,memory.free,driver_version,name,gpu_serial,display_active,display_mode,temperature.gpu", "--format=csv,noheader,nounits"], + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + creationflags=creationflags, + startupinfo=startupinfo + ) + stdout, _ = p.communicate() + output = stdout.decode('UTF-8', errors='ignore') + lines = [l for l in output.splitlines() if l.strip()] + GPUs = [] + for line in lines: + vals = [v.strip() for v in line.split(',')] + if len(vals) >= 12: + def _cast(s): + try: + return float(s) + except: + return float('nan') + deviceIds = int(vals[0]) if vals[0].isdigit() else 0 + uuid = vals[1] + gpuUtil = _cast(vals[2]) / 100 + memTotal = _cast(vals[3]) + memUsed = _cast(vals[4]) + memFree = _cast(vals[5]) + driver = vals[6] + gpu_name = vals[7] + serial = vals[8] + display_active = vals[9] + display_mode = vals[10] + temp_gpu = _cast(vals[11]) + GPUs.append(GPUtil.GPU(deviceIds, uuid, gpuUtil, memTotal, memUsed, memFree, driver, gpu_name, serial, display_mode, display_active, temp_gpu)) + return GPUs + except Exception: + return [] + + GPUtil.getGPUs = _no_window_getGPUs # CPU & disk sensors import psutil diff --git a/main.py b/main.py old mode 100755 new mode 100644 index dad5251f6..bf7448d74 --- a/main.py +++ b/main.py @@ -126,7 +126,7 @@ def on_configure_tray(tray_icon, item): # Load binary (for releases) or Python file with system interpreter configure_file = next(MAIN_DIRECTORY.glob("configure*")) if platform.system() == "Windows": - subprocess.Popen([str(configure_file)], shell=True) + subprocess.Popen([str(configure_file)], creationflags=0x08000000) else: subprocess.Popen([str(configure_file)]) diff --git a/simple-program.py b/simple-program.py old mode 100755 new mode 100644 index 501cf9314..2e1c08a40 --- a/simple-program.py +++ b/simple-program.py @@ -38,6 +38,7 @@ from library.lcd.lcd_comm_turing_usb import LcdCommTuringUSB from library.lcd.lcd_comm_weact_a import LcdCommWeActA from library.lcd.lcd_comm_weact_b import LcdCommWeActB +from library.lcd.lcd_comm_vmax import LcdCommVmax from library.lcd.lcd_simulated import LcdSimulated from library.log import logger @@ -49,11 +50,11 @@ # Display revision: see config.yaml comments for values # To identify your smart screen: https://github.com/mathoudebine/turing-smart-screen-python/wiki/Hardware-revisions -REVISION = "A" +REVISION = "VMAX" # Display width & height in pixels for portrait orientation # /!\ Do not switch width/height here for landscape, use lcd_comm.SetOrientation below -WIDTH, HEIGHT = 320, 480 +WIDTH, HEIGHT = 462, 1920 assert WIDTH <= HEIGHT, "Indicate display width/height for PORTRAIT orientation: width <= height" @@ -96,6 +97,9 @@ def sighandler(signum, frame): elif REVISION == "WEACT_B": logger.info("Selected Hardware WeAct Studio Display FS V1 0.96\"") lcd_comm = LcdCommWeActB(com_port=COM_PORT, display_width=WIDTH, display_height=HEIGHT) + elif REVISION in ("VMAX", "SOLARMAX"): + logger.info("Selected Hardware Solarmax / HL-VMAX (Artinchip)") + lcd_comm = LcdCommVmax(com_port=COM_PORT, display_width=WIDTH, display_height=HEIGHT) elif REVISION == "SIMU": logger.info("Selected Simulated LCD") lcd_comm = LcdSimulated(display_width=WIDTH, display_height=HEIGHT) diff --git a/tests/library/lcd/test_lcd_comm_vmax.py b/tests/library/lcd/test_lcd_comm_vmax.py new file mode 100644 index 000000000..3f5bb7efb --- /dev/null +++ b/tests/library/lcd/test_lcd_comm_vmax.py @@ -0,0 +1,40 @@ +import unittest +from PIL import Image + +from library.lcd.lcd_comm_vmax import LcdCommVmax, HANDSHAKE +from library.lcd.lcd_comm import Orientation +from .serial_mock import new_testing_serial +from .sample_image import generate_sample_image + + +class MockedLcdCommVmax(LcdCommVmax): + def openSerial(self): + self.lcd_serial = new_testing_serial() + + +class TestLcdCommVmax(unittest.TestCase): + def test_brightness(self): + lcd = MockedLcdCommVmax(display_width=462, display_height=1920) + lcd.SetBrightness(80) + writes = [args[0] for name, args, _ in lcd.lcd_serial.mock_calls if name == "write"] + self.assertIn(bytes([0xAA, 0xBB, 80, 0xCC, 0xDD]), writes) + + def test_display_pil_image_portrait(self): + lcd = MockedLcdCommVmax(display_width=100, display_height=200) + lcd.SetOrientation(Orientation.PORTRAIT) + img = Image.new("RGB", (100, 200), (255, 0, 0)) + lcd.DisplayPILImage(img) + writes = [args[0] for name, args, _ in lcd.lcd_serial.mock_calls if name == "write"] + self.assertTrue(len(writes) > 0) + self.assertTrue(writes[0].startswith(b"\xff\xd8\xff")) + self.assertTrue(writes[0].endswith(b"\xff\xd9")) + + def test_display_pil_image_landscape(self): + lcd = MockedLcdCommVmax(display_width=100, display_height=200) + lcd.SetOrientation(Orientation.LANDSCAPE) + img = Image.new("RGB", (200, 100), (0, 255, 0)) + lcd.DisplayPILImage(img) + writes = [args[0] for name, args, _ in lcd.lcd_serial.mock_calls if name == "write"] + self.assertTrue(len(writes) > 0) + self.assertTrue(writes[0].startswith(b"\xff\xd8\xff")) + self.assertTrue(writes[0].endswith(b"\xff\xd9")) diff --git a/tools/test-vmax-screen.py b/tools/test-vmax-screen.py new file mode 100644 index 000000000..7aa570c2e --- /dev/null +++ b/tools/test-vmax-screen.py @@ -0,0 +1,98 @@ +import sys +import os +import time +from pathlib import Path +from PIL import Image, ImageDraw, ImageFont + +# Add turing-smart-screen-python to sys.path +BASE_DIR = Path(__file__).resolve().parent +if str(BASE_DIR) not in sys.path: + sys.path.insert(0, str(BASE_DIR)) + +from library.lcd.lcd_comm_vmax import LcdCommVmax +from library.lcd.lcd_comm import Orientation +from library.log import logger + +print("==================================================") +print("Testing HL-VMAX / Solarmax in turing-smart-screen") +print("==================================================") + +try: + print("[1] Initializing LcdCommVmax (auto-detecting COM port)...") + lcd = LcdCommVmax(com_port="AUTO") + print(f" Opened port: {lcd.lcd_serial.port if lcd.lcd_serial else 'None'}") + + print("[2] Running InitializeComm & Handshake...") + lcd.InitializeComm() + print(f" Serial number: {lcd.serial_number}") + print(f" Detected size: {lcd.display_width} x {lcd.display_height}") + + print("[3] Setting brightness to 75%...") + lcd.SetBrightness(75) + + print("[4] Setting orientation to PORTRAIT...") + lcd.SetOrientation(Orientation.PORTRAIT) + + w = lcd.get_width() + h = lcd.get_height() + print(f"[5] Creating test dashboard image ({w}x{h})...") + + img = Image.new("RGB", (w, h), color=(15, 20, 30)) + draw = ImageDraw.Draw(img) + + # Decorative header + draw.rectangle([0, 0, w, 80], fill=(24, 75, 140)) + draw.text((20, 25), "TURING SMART SCREEN PYTHON", fill=(255, 255, 255)) + + # Sub-header + draw.rectangle([0, 85, w, 140], fill=(30, 40, 60)) + draw.text((20, 100), f"HL-VMAX Driver | {w}x{h} Native", fill=(0, 230, 180)) + + # System Status Card + draw.rounded_rectangle([20, 160, w - 20, 380], radius=15, fill=(25, 35, 50), outline=(50, 70, 100), width=2) + draw.text((40, 180), "HARDWARE INFO", fill=(200, 200, 200)) + draw.text((40, 220), f"Serial: {lcd.serial_number}", fill=(255, 255, 255)) + draw.text((40, 260), f"Port: {lcd.lcd_serial.port}", fill=(255, 255, 255)) + draw.text((40, 300), f"Resolution: {w} x {h}", fill=(0, 255, 200)) + draw.text((40, 340), "Driver Status: ONLINE & WORKING", fill=(50, 255, 50)) + + # CPU Load simulation bar + draw.rounded_rectangle([20, 410, w - 20, 560], radius=15, fill=(25, 35, 50), outline=(50, 70, 100), width=2) + draw.text((40, 430), "CPU USAGE (SIMULATED)", fill=(200, 200, 200)) + draw.text((40, 465), "Load: 42%", fill=(255, 255, 255)) + draw.rounded_rectangle([40, 505, w - 40, 535], radius=6, fill=(40, 50, 70)) + bar_w = int((w - 80) * 0.42) + draw.rounded_rectangle([40, 505, 40 + bar_w, 535], radius=6, fill=(0, 180, 255)) + + # RAM Load simulation bar + draw.rounded_rectangle([20, 590, w - 20, 740], radius=15, fill=(25, 35, 50), outline=(50, 70, 100), width=2) + draw.text((40, 610), "RAM USAGE (SIMULATED)", fill=(200, 200, 200)) + draw.text((40, 645), "Used: 68% (21.7 / 32 GB)", fill=(255, 255, 255)) + draw.rounded_rectangle([40, 685, w - 40, 715], radius=6, fill=(40, 50, 70)) + bar_ram = int((w - 80) * 0.68) + draw.rounded_rectangle([40, 685, 40 + bar_ram, 715], radius=6, fill=(180, 80, 255)) + + # GPU Status card + draw.rounded_rectangle([20, 770, w - 20, 920], radius=15, fill=(25, 35, 50), outline=(50, 70, 100), width=2) + draw.text((40, 790), "GPU STATUS (SIMULATED)", fill=(200, 200, 200)) + draw.text((40, 825), "Load: 25% | Temp: 48°C", fill=(255, 255, 255)) + draw.rounded_rectangle([40, 865, w - 40, 895], radius=6, fill=(40, 50, 70)) + bar_gpu = int((w - 80) * 0.25) + draw.rounded_rectangle([40, 865, 40 + bar_gpu, 895], radius=6, fill=(0, 230, 120)) + + # Footer + draw.rectangle([0, h - 60, w, h], fill=(20, 25, 35)) + draw.text((20, h - 42), "Turing Smart Screen Python • HL-VMAX Driver", fill=(120, 140, 170)) + + print("[6] Sending frame via DisplayPILImage...") + lcd.DisplayPILImage(img, 0, 0, w, h) + print("[7] Frame sent successfully to the screen!") + + time.sleep(1) + lcd.closeSerial() + print("Test finished successfully!") + +except Exception as e: + import traceback + print("Error during test:") + traceback.print_exc() diff --git a/turing-system-monitor.spec b/turing-system-monitor.spec index 1f1c3c11a..7f7245a91 100644 --- a/turing-system-monitor.spec +++ b/turing-system-monitor.spec @@ -7,7 +7,7 @@ configure_a = Analysis( pathex=[], binaries=[], datas=[('res', 'res'), ('config.yaml', '.'), ('external', 'external')], - hiddenimports=['PIL', 'PIL._imagingtk', 'PIL._tkinter_finder'], + hiddenimports=['PIL', 'PIL._imagingtk', 'PIL._tkinter_finder', 'clr', 'pythonnet', 'clr_loader'], hookspath=[], hooksconfig={}, runtime_hooks=[], @@ -45,7 +45,7 @@ main_a = Analysis( pathex=[], binaries=[], datas=[('res', 'res'), ('config.yaml', '.'), ('external', 'external')], - hiddenimports=['PIL', 'PIL._imagingtk', 'PIL._tkinter_finder'], + hiddenimports=['PIL', 'PIL._imagingtk', 'PIL._tkinter_finder', 'clr', 'pythonnet', 'clr_loader'], hookspath=[], hooksconfig={}, runtime_hooks=[], @@ -83,7 +83,7 @@ editor_a = Analysis( pathex=[], binaries=[], datas=[('res', 'res'), ('config.yaml', '.'), ('external', 'external')], - hiddenimports=['PIL', 'PIL._imagingtk', 'PIL._tkinter_finder'], + hiddenimports=['PIL', 'PIL._imagingtk', 'PIL._tkinter_finder', 'clr', 'pythonnet', 'clr_loader'], hookspath=[], hooksconfig={}, runtime_hooks=[],