|
| 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 | +# Copyright (C) 2021 Matthieu Houdebine (mathoudebine) |
| 7 | +# |
| 8 | +# This program is free software: you can redistribute it and/or modify |
| 9 | +# it under the terms of the GNU General Public License as published by |
| 10 | +# the Free Software Foundation, either version 3 of the License, or |
| 11 | +# (at your option) any later version. |
| 12 | +# |
| 13 | +# This program is distributed in the hope that it will be useful, |
| 14 | +# but WITHOUT ANY WARRANTY; without even the implied warranty of |
| 15 | +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the |
| 16 | +# GNU General Public License for more details. |
| 17 | +# |
| 18 | +# You should have received a copy of the GNU General Public License |
| 19 | +# along with this program. If not, see <https://www.gnu.org/licenses/>. |
| 20 | + |
| 21 | +import math |
1 | 22 | import platform |
2 | 23 | import queue |
3 | 24 | import struct |
|
12 | 33 | from Crypto.Cipher import DES |
13 | 34 | from PIL import Image |
14 | 35 |
|
| 36 | +from library.log import logger |
15 | 37 | from library.lcd.lcd_comm import Orientation, LcdComm |
16 | 38 |
|
17 | 39 | VENDOR_ID = 0x1cbe |
18 | 40 | PRODUCT_ID = 0x0088 |
19 | 41 |
|
20 | 42 |
|
| 43 | +MAX_CHUNK_BYTES = 1024*1024 # Data sent to screen cannot exceed 1024MB or there will be a timeout |
| 44 | + |
| 45 | + |
21 | 46 | def build_command_packet_header(a0: int) -> bytearray: |
22 | 47 | packet = bytearray(500) |
23 | 48 | packet[0] = a0 |
@@ -288,17 +313,185 @@ def send_video(dev, video_path, loop=False): |
288 | 313 | write_to_device(dev, encrypt_command_packet(build_command_packet_header(123))) |
289 | 314 |
|
290 | 315 |
|
| 316 | +def _encode_png(image: Image.Image) -> bytes: |
| 317 | + buffer = BytesIO() |
| 318 | + image.save(buffer, format="PNG", compress_level=9) |
| 319 | + return buffer.getvalue() |
| 320 | + |
| 321 | + |
| 322 | +def compress_image(image: Image.Image, ratio: float) -> Image.Image: |
| 323 | + width, height = image.size |
| 324 | + image = image.resize((int(width * ratio*0.5), int(height * ratio*0.5)), |
| 325 | + resample=Image.Resampling.LANCZOS) |
| 326 | + image = image.resize((width, height)) |
| 327 | + return image |
| 328 | + |
| 329 | + |
| 330 | + |
| 331 | +def upload_file(dev, file_path: str) -> bool: |
| 332 | + local_path = Path(file_path) |
| 333 | + if not local_path.exists(): |
| 334 | + logger.error("Error: File does not exist: %s", file_path) |
| 335 | + return False |
| 336 | + |
| 337 | + ext = local_path.suffix.lower() |
| 338 | + if ext == ".png": |
| 339 | + device_path = f"/tmp/sdcard/mmcblk0p1/img/{local_path.name}" |
| 340 | + logger.info("Uploading PNG: %s → %s", file_path, device_path) |
| 341 | + elif ext == ".mp4": |
| 342 | + h264_path = extract_h264_from_mp4(file_path) |
| 343 | + device_path = f"/tmp/sdcard/mmcblk0p1/video/{h264_path.name}" |
| 344 | + local_path = h264_path # Update local path to .h264 |
| 345 | + logger.info("Uploading MP4 as H264: %s → %s", local_path, device_path) |
| 346 | + else: |
| 347 | + logger.error("Error: Unsupported file type. Only .png and .mp4 are allowed.") |
| 348 | + return False |
| 349 | + |
| 350 | + if not _open_file_command(dev, device_path): |
| 351 | + logger.error("Failed to open remote file for writing.") |
| 352 | + return False |
| 353 | + |
| 354 | + if not _write_file_command(dev, str(local_path)): |
| 355 | + logger.error("Failed to write file data.") |
| 356 | + return False |
| 357 | + |
| 358 | + logger.info("Upload completed successfully.") |
| 359 | + return True |
| 360 | + |
| 361 | + |
| 362 | +def _open_file_command(dev, path: str): |
| 363 | + logger.info("Opening remote file: %s", path) |
| 364 | + |
| 365 | + path_bytes = path.encode("ascii") |
| 366 | + length = len(path_bytes) |
| 367 | + |
| 368 | + packet = build_command_packet_header(38) |
| 369 | + |
| 370 | + packet[8] = (length >> 24) & 0xFF |
| 371 | + packet[9] = (length >> 16) & 0xFF |
| 372 | + packet[10] = (length >> 8) & 0xFF |
| 373 | + packet[11] = length & 0xFF |
| 374 | + packet[12:16] = b"\x00\x00\x00\x00" |
| 375 | + packet[16 : 16 + length] = path_bytes |
| 376 | + |
| 377 | + return write_to_device(dev, encrypt_command_packet(packet)) |
| 378 | + |
| 379 | + |
| 380 | +def _delete_command(dev, file_path: str): |
| 381 | + logger.info("Deleting remote file: %s", file_path) |
| 382 | + |
| 383 | + path_bytes = file_path.encode("ascii") |
| 384 | + length = len(path_bytes) |
| 385 | + |
| 386 | + packet = build_command_packet_header(40) |
| 387 | + packet[8] = (length >> 24) & 0xFF |
| 388 | + packet[9] = (length >> 16) & 0xFF |
| 389 | + packet[10] = (length >> 8) & 0xFF |
| 390 | + packet[11] = length & 0xFF |
| 391 | + packet[12:16] = b"\x00\x00\x00\x00" |
| 392 | + packet[16 : 16 + length] = path_bytes |
| 393 | + |
| 394 | + return write_to_device(dev, encrypt_command_packet(packet)) |
| 395 | + |
| 396 | + |
| 397 | +def _play_command(dev, file_path: str): |
| 398 | + logger.info("Requesting playback for: %s", file_path) |
| 399 | + |
| 400 | + path_bytes = file_path.encode("ascii") |
| 401 | + length = len(path_bytes) |
| 402 | + |
| 403 | + packet = build_command_packet_header(98) |
| 404 | + |
| 405 | + packet[8] = (length >> 24) & 0xFF |
| 406 | + packet[9] = (length >> 16) & 0xFF |
| 407 | + packet[10] = (length >> 8) & 0xFF |
| 408 | + packet[11] = length & 0xFF |
| 409 | + packet[12:16] = b"\x00\x00\x00\x00" |
| 410 | + packet[16 : 16 + length] = path_bytes |
| 411 | + |
| 412 | + return write_to_device(dev, encrypt_command_packet(packet)) |
| 413 | + |
| 414 | + |
| 415 | +def _play2_command(dev, file_path: str): |
| 416 | + logger.info("Requesting alternate playback for: %s", file_path) |
| 417 | + |
| 418 | + path_bytes = file_path.encode("ascii") |
| 419 | + length = len(path_bytes) |
| 420 | + |
| 421 | + packet = build_command_packet_header(110) |
| 422 | + |
| 423 | + packet[8] = (length >> 24) & 0xFF |
| 424 | + packet[9] = (length >> 16) & 0xFF |
| 425 | + packet[10] = (length >> 8) & 0xFF |
| 426 | + packet[11] = length & 0xFF |
| 427 | + packet[12:16] = b"\x00\x00\x00\x00" |
| 428 | + packet[16 : 16 + length] = path_bytes |
| 429 | + |
| 430 | + return write_to_device(dev, encrypt_command_packet(packet)) |
| 431 | + |
| 432 | + |
| 433 | +def _play3_command(dev, file_path: str): |
| 434 | + logger.info("Requesting image playback for: %s", file_path) |
| 435 | + |
| 436 | + path_bytes = file_path.encode("ascii") |
| 437 | + length = len(path_bytes) |
| 438 | + |
| 439 | + packet = build_command_packet_header(113) |
| 440 | + |
| 441 | + packet[8] = (length >> 24) & 0xFF |
| 442 | + packet[9] = (length >> 16) & 0xFF |
| 443 | + packet[10] = (length >> 8) & 0xFF |
| 444 | + packet[11] = length & 0xFF |
| 445 | + packet[12:16] = b"\x00\x00\x00\x00" |
| 446 | + packet[16 : 16 + length] = path_bytes |
| 447 | + |
| 448 | + return write_to_device(dev, encrypt_command_packet(packet)) |
| 449 | + |
| 450 | + |
| 451 | +def _write_file_command(dev, file_path: str) -> bool: |
| 452 | + logger.info("Writing remote file from: %s", file_path) |
| 453 | + |
| 454 | + try: |
| 455 | + with open(file_path, "rb") as fh: |
| 456 | + chunk_index = 0 |
| 457 | + while True: |
| 458 | + data_chunk = fh.read(202752) |
| 459 | + if not data_chunk: |
| 460 | + break |
| 461 | + |
| 462 | + chunk_size = len(data_chunk) |
| 463 | + chunk_index += 1 |
| 464 | + logger.debug("Chunk %d size: %d bytes", chunk_index, chunk_size) |
| 465 | + |
| 466 | + cmd_packet = build_command_packet_header(39) |
| 467 | + cmd_packet[8] = (chunk_size >> 24) & 0xFF |
| 468 | + cmd_packet[9] = (chunk_size >> 16) & 0xFF |
| 469 | + cmd_packet[10] = (chunk_size >> 8) & 0xFF |
| 470 | + cmd_packet[11] = chunk_size & 0xFF |
| 471 | + |
| 472 | + response = write_to_device(dev, encrypt_command_packet(cmd_packet) + data_chunk) |
| 473 | + if response is None: |
| 474 | + logger.error("Write command failed at chunk %d", chunk_index) |
| 475 | + return False |
| 476 | + |
| 477 | + logger.info("File write completed successfully (%d chunks).", chunk_index) |
| 478 | + return True |
| 479 | + except FileNotFoundError: |
| 480 | + logger.error("File not found: %s", file_path) |
| 481 | + return False |
| 482 | + except Exception as exc: |
| 483 | + logger.error("Error writing file: %s", exc) |
| 484 | + return False |
| 485 | + |
291 | 486 | # This class is for Turing Smart Screen newer models (5.2" / 8" / 8.8" HW rev 1.x / 9.2") |
292 | | -class LcdCommRevCUSB(LcdComm): |
| 487 | +# These models are not detected as serial ports but as (Win)USB devices |
| 488 | +class LcdCommTuringUSB(LcdComm): |
293 | 489 | def __init__(self, com_port: str = "AUTO", display_width: int = 480, display_height: int = 1920, |
294 | 490 | update_queue: Optional[queue.Queue] = None): |
295 | 491 | super().__init__(com_port, display_width, display_height, update_queue) |
296 | 492 | self.dev = find_usb_device() |
297 | 493 | # Store the current screen state as an image that will be continuously updated and sent |
298 | | - self.current_state = Image.new("RGBA", (self.get_width(), self.get_height()), (0, 0, 0, 255)) |
299 | | - |
300 | | - def auto_detect_com_port(self): |
301 | | - pass |
| 494 | + self.current_state = Image.new("RGBA", (self.get_width(), self.get_height()), (0, 0, 0, 0)) |
302 | 495 |
|
303 | 496 | def InitializeComm(self): |
304 | 497 | send_sync_command(self.dev) |
@@ -327,6 +520,8 @@ def SetBrightness(self, level: int = 25): |
327 | 520 |
|
328 | 521 | def SetOrientation(self, orientation: Orientation): |
329 | 522 | self.orientation = orientation |
| 523 | + # Recreate new state with correct width/height now that screen orientation has changed |
| 524 | + self.current_state = Image.new("RGBA", (self.get_width(), self.get_height()), (0, 0, 0, 0)) |
330 | 525 |
|
331 | 526 | def DisplayPILImage(self, image: Image.Image, x: int = 0, y: int = 0, image_width: int = 0, image_height: int = 0): |
332 | 527 | if not image_height: |
@@ -355,9 +550,22 @@ def DisplayPILImage(self, image: Image.Image, x: int = 0, y: int = 0, image_widt |
355 | 550 | else: # Orientation.REVERSE_PORTRAIT is initial screen orientation |
356 | 551 | base_image = self.current_state |
357 | 552 |
|
358 | | - # Save as PNG format with headers |
359 | | - buffer = BytesIO() |
360 | | - base_image.save(buffer, format="PNG") |
| 553 | + # total_size = len(_encode_png(base_image)) |
| 554 | + # print("total size =", total_size/1024) |
| 555 | + # |
| 556 | + # if total_size > 1024*1024: |
| 557 | + # |
| 558 | + # # If bitmap is > 1024MB operation will timeout: compress it |
| 559 | + # size_overflow = total_size - 1024*1024 |
| 560 | + # ratio = 1- (size_overflow / total_size) |
| 561 | + # print("ratio = ", ratio) |
| 562 | + # |
| 563 | + # base_image = compress_image(base_image, ratio) |
| 564 | + # |
| 565 | + # new_size = len(_encode_png(base_image)) |
| 566 | + # print("new_size =", new_size/1024) |
| 567 | + |
361 | 568 |
|
362 | 569 | # Send PNG data |
363 | | - send_image(self.dev, buffer.getvalue()) |
| 570 | + encoded = _encode_png(base_image) |
| 571 | + send_image(self.dev, encoded) |
0 commit comments