diff --git a/README.md b/README.md index eb95e732d..3b1737b7d 100644 --- a/README.md +++ b/README.md @@ -19,7 +19,7 @@ Supported operating systems : macOS, Windows, Linux (incl. Raspberry Pi), basica | ✅ Turing Smart Screen / TURZX | |------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| |
| -| All available sizes and hardware revisions supported: **2.1" / 2.8" / 3.5" / 4.6" / 5" / 5.2" / 8.0" / 8.8" / 9.2" / 12.3"**
UART and USB protocols supported. Note: no video or storage support for now | +| All available sizes and hardware revisions supported: **2.1" / 2.8" / 3.5" / 4.6" / 5" / 5.2" / 8.0" / 8.8" / 9.2" / 11.3" / 12.3"**
UART and USB protocols supported. Note: no video or storage support for now | | ✅ XuanFang 3.5" | ✅ [UsbPCMonitor 3.5" / 5"](https://aliexpress.com/item/1005003931363455.html) | ✅ Kipye Qiye Smart Display 3.5" | |---------------------------------------------------|-----------------------------------------------------------------------------------------------------|----------------------------------------------------------------------------------| diff --git a/config.yaml b/config.yaml index 8aef493e4..e8e52317c 100644 --- a/config.yaml +++ b/config.yaml @@ -54,7 +54,7 @@ display: # Display revision: # - A for Turing 3.5" and UsbPCMonitor 3.5"/5" # - B for Xuanfang 3.5" (inc. flagship) - # - C for Turing 2.1"/2.8"/5"/8.8" + # - C for Turing 2.1"/2.8"/5"/8.8"/11.3" # - D for Kipye Qiye Smart Display 3.5" # - 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" diff --git a/configure.py b/configure.py index 3f393a147..a8289e62e 100755 --- a/configure.py +++ b/configure.py @@ -76,6 +76,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\"" SIZE_12_3_INCH = "12.3\"" SIZE_2_8_INCH_NEWREV = "2.8\" round (V1.X new HW rev.)" @@ -91,6 +92,7 @@ SIZE_8_INCH, SIZE_8_8_INCH, SIZE_8_8_INCH_NEWREV, + SIZE_11_3_INCH, SIZE_12_3_INCH, ) @@ -103,6 +105,7 @@ ('C', SIZE_2_x_INCH): TURING_MODEL, ('C', SIZE_5_INCH): TURING_MODEL, ('C', SIZE_8_8_INCH): TURING_MODEL, + ('C', SIZE_11_3_INCH): TURING_MODEL, ('D', SIZE_3_5_INCH): KIPYE_MODEL, ('TUR_USB', SIZE_4_6_INCH): TURING_MODEL, ('TUR_USB', SIZE_5_2_INCH): TURING_MODEL, @@ -122,6 +125,7 @@ ('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 = { @@ -133,6 +137,7 @@ (TURING_MODEL, SIZE_5_INCH): 'C', (TURING_MODEL, SIZE_8_INCH): 'TUR_USB', (TURING_MODEL, SIZE_8_8_INCH): 'C', + (TURING_MODEL, SIZE_11_3_INCH): 'C', (TURING_MODEL, SIZE_8_8_INCH_NEWREV): 'TUR_USB', (TURING_MODEL, SIZE_12_3_INCH): 'TUR_USB', (TURING_MODEL, SIZE_2_8_INCH_NEWREV): 'TUR_USB', @@ -150,6 +155,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"} @@ -629,6 +635,9 @@ def on_size_change(self, e=None): elif size == SIZE_8_8_INCH_NEWREV or size == SIZE_8_8_INCH: themes = get_themes(SIZE_8_8_INCH) themes += get_themes(_SIZE_9_2_INCH) + elif size == SIZE_11_3_INCH: + themes = get_themes(SIZE_11_3_INCH) + themes += get_themes(SIZE_8_8_INCH) else: themes = get_themes(size) diff --git a/library/display.py b/library/display.py index dabbca52c..364ff4112 100644 --- a/library/display.py +++ b/library/display.py @@ -74,6 +74,8 @@ def _get_theme_size() -> tuple[int, int]: return 800, 1280 elif config.THEME_DATA["display"].get("DISPLAY_SIZE", '') == '8.8"': return 480, 1920 + elif config.THEME_DATA["display"].get("DISPLAY_SIZE", '') == '11.3"': + return 440, 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", '') == '12.3"': diff --git a/library/lcd/lcd_comm_rev_c.py b/library/lcd/lcd_comm_rev_c.py index 75fe73a2f..d740ee9f0 100644 --- a/library/lcd/lcd_comm_rev_c.py +++ b/library/lcd/lcd_comm_rev_c.py @@ -27,6 +27,7 @@ from math import ceil from typing import Optional, Tuple +import numpy as np import serial from PIL import Image from serial.tools.list_ports import comports @@ -75,6 +76,10 @@ class Command(Enum): # STOP COMMANDS STOP_VIDEO = bytearray((0x79, 0xef, 0x69, 0x00, 0x00, 0x00, 0x01)) STOP_MEDIA = bytearray((0x96, 0xef, 0x69, 0x00, 0x00, 0x00, 0x01)) + # 11.3" / chs_113inch vendor sequence (ShinySnake G600 / TURZX) + PRE_FULL_FRAME = bytearray((0x81, 0xef, 0x69, 0x00, 0x00, 0x00, 0x01)) + STOP_SESSION = bytearray((0x87, 0xef, 0x69, 0x00, 0x00, 0x00, 0x01)) + OPTIONS_113INCH = bytearray((0x7d, 0xef, 0x69, 0x00, 0x00, 0x00, 0x05, 0x00, 0x00, 0x00, 0xaa)) # IMAGE QUERY STATUS QUERY_STATUS = bytearray((0xcf, 0xef, 0x69, 0x00, 0x00, 0x00, 0x01)) @@ -86,6 +91,7 @@ class Command(Enum): DISPLAY_BITMAP_2INCH = bytearray((0xc8, 0xef, 0x69, 0x00)) + bytearray((0x0E, 0x10)) DISPLAY_BITMAP_5INCH = bytearray((0xc8, 0xef, 0x69, 0x00)) + bytearray((0x17, 0x70)) DISPLAY_BITMAP_8INCH = bytearray((0xc8, 0xef, 0x69, 0x00)) + bytearray((0x38, 0x40)) + DISPLAY_BITMAP_113INCH = bytearray((0xc8, 0xef, 0x69, 0x00)) + bytearray((0x33, 0x90)) STARTMODE_DEFAULT = bytearray((0x00,)) STARTMODE_IMAGE = bytearray((0x01,)) @@ -119,22 +125,46 @@ class SubRevision(Enum): REV_2INCH = 1 # For 2.1" and 2.8" models REV_5INCH = 2 REV_8INCH = 3 + REV_113INCH = 4 + + +class Rev113Geometry: + # Every other Rev C size's C8 wire canvas matches its glass 1:1, so + # display_width/display_height (from LcdComm) are enough on their own. + # 11.3" is the only size where that's not true. Its C8 canvas is a + # reshape of the glass, not the same shape. So it's the only one that + # needs a second set of dimensions at all. + C8_WIDTH = 1760 + C8_HEIGHT = 480 + GLASS_WIDTH = 440 + GLASS_HEIGHT = 1920 + REV_113_WIDTH = GLASS_WIDTH + REV_113_HEIGHT = GLASS_HEIGHT WAKE_RETRIES = 15 -# This class is for Turing Smart Screen 2.1" / 2.8" / 5" / 8" screens +# This class is for Turing Smart Screen 2.1" / 2.8" / 5" / 8.8" / 11.3" screens class LcdCommRevC(LcdComm): def __init__(self, com_port: str = "AUTO", display_width: int = 480, display_height: int = 800, update_queue: Optional[queue.Queue] = None): logger.debug("HW revision: C") LcdComm.__init__(self, com_port, display_width, display_height, update_queue) + self._last_status_ts_113inch = 0.0 self.openSerial() def __del__(self): self.closeSerial() + def openSerial(self): + LcdComm.openSerial(self) + # Full 11.3" frames are ~3.4 MB; the default write timeout is too short. + # Set here rather than in __init__ so it survives the reconnect that + # WriteLine/ReadData perform on SerialException. + if self.lcd_serial is not None and self._subrevision_from_size() == SubRevision.REV_113INCH: + self.lcd_serial.write_timeout = 20 + @staticmethod def auto_detect_com_port() -> Optional[str]: # If sleeping device is detected through serial number or vid/pid, try to wake it up @@ -212,34 +242,54 @@ def _send_command(self, cmd: Command, payload: Optional[bytearray] = None, paddi if readsize: self.update_queue.put((self.ReadData, [readsize])) + def _subrevision_from_size(self) -> SubRevision: + if self.display_width == 480 and self.display_height == 480: + return SubRevision.REV_2INCH + if self.display_width == 480 and self.display_height == 800: + return SubRevision.REV_5INCH + if self.display_width == 480 and self.display_height == 1920: + return SubRevision.REV_8INCH + if self.display_width == Rev113Geometry.REV_113_WIDTH and self.display_height == Rev113Geometry.REV_113_HEIGHT: + return SubRevision.REV_113INCH + return SubRevision.UNKNOWN + + @staticmethod + def _decode_hello(raw: bytes) -> str: + # IDs are ASCII and model-dependent length. The 8.8" ID is 23 chars + # (chs_88inch.dev1_rom1.90); the 11.3" ID is 24 + # (chs_113inch.dev1_rom1.90). A hardcoded 23-byte read truncates the + # latter to rom1.9 and the ROM parse falls back to 87 (BGR instead of BGRA). + return ''.join(c for c in raw.decode(errors="ignore") if c in set(string.printable)).split('\x00')[0].strip() + + def _read_hello(self) -> str: + self._send_command(Command.HELLO, bypass_queue=True) + return self._decode_hello(self.serial_read(64)) + def _hello(self): # This command reads LCD answer on serial link, so it bypasses the queue self.sub_revision = SubRevision.UNKNOWN self.serial_flush_input() - self._send_command(Command.HELLO, bypass_queue=True) - response = ''.join( - filter(lambda x: x in set(string.printable), str(self.serial_read(23).decode(errors="ignore")))) + response = self._read_hello() self.serial_flush_input() logger.debug("Display ID returned: %s" % response) while not response.startswith("chs_"): logger.warning("Display returned invalid or unsupported ID, try again in 1 second") time.sleep(1) - self._send_command(Command.HELLO, bypass_queue=True) - response = ''.join( - filter(lambda x: x in set(string.printable), str(self.serial_read(23).decode(errors="ignore")))) + response = self._read_hello() self.serial_flush_input() logger.debug("Display ID returned: %s" % response) - # Note: ID returned by display are not reliable for some models e.g. 2.1" displays return "chs_5inch" - # Rely on width/height for sub-revision detection - if self.display_width == 480 and self.display_height == 480: - self.sub_revision = SubRevision.REV_2INCH - elif self.display_width == 480 and self.display_height == 800: - self.sub_revision = SubRevision.REV_5INCH - elif self.display_width == 480 and self.display_height == 1920: - self.sub_revision = SubRevision.REV_8INCH + # 11.3" identifies itself; do not classify it as the 8.8" (also 480x1920). + if response.startswith("chs_113inch"): + self.sub_revision = SubRevision.REV_113INCH + self.display_width = Rev113Geometry.REV_113_WIDTH + self.display_height = Rev113Geometry.REV_113_HEIGHT else: - logger.error(f"Unsupported resolution {self.display_width}x{self.display_height} for revision C") + # Note: ID returned by display are not reliable for some models e.g. 2.1" displays return "chs_5inch" + # Rely on width/height for sub-revision detection + self.sub_revision = self._subrevision_from_size() + if self.sub_revision == SubRevision.UNKNOWN: + logger.error(f"Unsupported resolution {self.display_width}x{self.display_height} for revision C") # Detect ROM version try: @@ -247,7 +297,7 @@ def _hello(self): if self.rom_version < 80 or self.rom_version > 100: logger.warning("ROM version %d may be invalid, use default ROM version 87" % self.rom_version) self.rom_version = 87 - except: + except Exception: logger.warning("Display returned invalid or unsupported ID, use default ROM version 87") self.rom_version = 87 @@ -257,6 +307,15 @@ def InitializeComm(self): self._hello() def Reset(self): + if self.display_width == Rev113Geometry.REV_113_WIDTH and self.display_height == Rev113Geometry.REV_113_HEIGHT: + self.serial_flush_input() + ident = self._read_hello() + self.serial_flush_input() + if ident.startswith("chs_113inch"): + logger.info("11.3\" panel: skipping firmware RESTART") + return + # Declared size matched but the panel didn't identify as 11.3" -- fall through to normal RESTART. + logger.info("Display reset (COM port may change)...") # Reset command bypasses queue because it is run when queue threads are not yet started self._send_command(Command.RESTART, bypass_queue=True) @@ -294,6 +353,9 @@ def ScreenOn(self): # logger.info("Calling ScreenOn") self._send_command(Command.STOP_VIDEO) self._send_command(Command.STOP_MEDIA, readsize=1024) + if getattr(self, "sub_revision", None) == SubRevision.REV_113INCH: + # Vendor sends 0x81 after STOP_MEDIA before the first frame. + self._send_command(Command.PRE_FULL_FRAME) # self._send_command(Command.SET_BRIGHTNESS, payload=bytearray([255])) def SetBrightness(self, level: int = 25): @@ -314,6 +376,10 @@ def SetOrientation(self, orientation: Orientation = Orientation.PORTRAIT): # b = Command.STARTMODE_DEFAULT.value + Padding.NULL.value + Command.FLIP_180.value + SleepInterval.OFF.value # self._send_command(Command.OPTIONS, payload=b) # else: + if getattr(self, "sub_revision", None) == SubRevision.REV_113INCH: + # Vendor payload is 0xAA rather than STARTMODE_DEFAULT (0x2D). + self._send_command(Command.OPTIONS_113INCH) + return b = Command.STARTMODE_DEFAULT.value + Padding.NULL.value + Command.NO_FLIP.value + SleepInterval.OFF.value self._send_command(Command.OPTIONS, payload=b) @@ -324,6 +390,21 @@ def DisplayPILImage( image_width: int = 0, image_height: int = 0 ): + # For full-screen images on the 11.3" display route through a custm full-frame sender + # Needed since on the 11.3" the c8 canvas size != the physical glass size + if ( + getattr(self, "sub_revision", None) == SubRevision.REV_113INCH + and x == 0 and y == 0 + and image.size in ( + (Rev113Geometry.GLASS_WIDTH, Rev113Geometry.GLASS_HEIGHT), # 440×1920 declared theme size == native glass + (Rev113Geometry.C8_WIDTH, Rev113Geometry.C8_HEIGHT), # 1760×480 native C8 wire canvas + (480, Rev113Geometry.GLASS_HEIGHT), # 480×1920 borrowed 8.8" theme, scale don't crop + ) + ): + with self.update_queue_mutex: + self._display_full_113inch(image) + return + # If the image height/width isn't provided, use the native image size if not image_height: image_height = image.size[1] @@ -346,33 +427,154 @@ def DisplayPILImage( if x == 0 and y == 0 and (image_width == self.get_width()) and (image_height == self.get_height()): with self.update_queue_mutex: - self._send_command(Command.PRE_UPDATE_BITMAP) - self._send_command(Command.START_DISPLAY_BITMAP, padding=Padding.START_DISPLAY_BITMAP) - - if self.sub_revision == SubRevision.REV_5INCH: - display_bmp_cmd = Command.DISPLAY_BITMAP_5INCH - elif self.sub_revision == SubRevision.REV_2INCH: - display_bmp_cmd = Command.DISPLAY_BITMAP_2INCH - elif self.sub_revision == SubRevision.REV_8INCH: - display_bmp_cmd = Command.DISPLAY_BITMAP_8INCH - - self._send_command(display_bmp_cmd, - payload=bytearray( - int(self.display_width * self.display_width / 64).to_bytes(2, "big"))) - self._send_command(Command.SEND_PAYLOAD, - payload=bytearray(self._generate_full_image(image)), - readsize=1024) - self._send_command(Command.QUERY_STATUS, readsize=1024) + if self.sub_revision == SubRevision.REV_113INCH: + self._display_full_113inch(image) + else: + self._send_command(Command.PRE_UPDATE_BITMAP) + self._send_command(Command.START_DISPLAY_BITMAP, padding=Padding.START_DISPLAY_BITMAP) + + if self.sub_revision == SubRevision.REV_5INCH: + display_bmp_cmd = Command.DISPLAY_BITMAP_5INCH + elif self.sub_revision == SubRevision.REV_2INCH: + display_bmp_cmd = Command.DISPLAY_BITMAP_2INCH + elif self.sub_revision == SubRevision.REV_8INCH: + display_bmp_cmd = Command.DISPLAY_BITMAP_8INCH + else: + display_bmp_cmd = Command.DISPLAY_BITMAP_8INCH + + self._send_command(display_bmp_cmd, + payload=bytearray( + int(self.display_width * self.display_width / 64).to_bytes(2, "big"))) + self._send_command(Command.SEND_PAYLOAD, + payload=bytearray(self._generate_full_image(image)), + readsize=1024) + self._send_command(Command.QUERY_STATUS, readsize=1024) else: with self.update_queue_mutex: img, pyd = self._generate_update_image(image, x, y, Count.Start, Command.UPDATE_BITMAP) - self._send_command(Command.SEND_PAYLOAD, payload=pyd) - self._send_command(Command.SEND_PAYLOAD, payload=img) - self._send_command(Command.QUERY_STATUS, readsize=1024) + if self.sub_revision == SubRevision.REV_113INCH: + # Poll status at most ~1 Hz; faster 0xcf jams this ROM. + self._maybe_query_status_113inch() + self._send_command(Command.SEND_PAYLOAD, payload=pyd) + self._send_command(Command.SEND_PAYLOAD, payload=img) + else: + self._send_command(Command.SEND_PAYLOAD, payload=pyd) + self._send_command(Command.SEND_PAYLOAD, payload=img) + self._send_command(Command.QUERY_STATUS, readsize=1024) Count.Start += 1 + def _display_full_113inch(self, image: Image.Image): + """Full-frame 0xc8 path for chs_113inch. + + PRE_UPDATE + 0x2C block + 0xc8 00 33 90, BGRA with a 0x00 every 249 + bytes, no 0xEF69 terminator, padded to 250. Sent twice. ACK is the + ASCII string 'full_png_sucess'. No extra W*W/64 size word. + + Every send here uses bypass_queue=True. _write_113inch_c8_body/ + _wait_113inch_full_png_success always write/read the serial port directly + (there's no way to chunk a body write or an ACK wait through + update_queue), so if the header commands above were left to go + through the queue instead, main.py's real update_queue could + dequeue and send the body on a *different* thread before the + queued header command actually went out -- a real ordering race + that only shows up when a queue is configured (i.e. real main.py + use, not a standalone script that constructs the driver without + one). That's what caused the intermittent "missing full_png_sucess" + seen running a real theme end-to-end 2026-08-17: the background + frame silently raced its own header and got dropped. + """ + self._send_command(Command.PRE_UPDATE_BITMAP, bypass_queue=True) + self._send_command(Command.START_DISPLAY_BITMAP, padding=Padding.START_DISPLAY_BITMAP, bypass_queue=True) + payload = bytearray(self._generate_full_image(image)) + # Send twice and wait for 'full_png_sucess' after each. Without the + # ACK wait the second copy piles onto the first and mixed pixels tear. + for _ in range(2): + self._send_command(Command.DISPLAY_BITMAP_113INCH, bypass_queue=True) + # Write the body in 25 KiB chunks; a single 3.4 MB write returns + # as soon as the tty buffer fills. + self._write_113inch_c8_body(payload) + ack = self._wait_113inch_full_png_success() + if "full_png_sucess" not in ack: + logger.warning("11.3\" C8: missing full_png_sucess (%r)", ack[:80]) + # Vendor restarts the 0xcc sequence counter after a new full frame. + Count.Start = 0 + self._last_status_ts_113inch = 0.0 + + def _write_113inch_c8_body(self, payload: bytearray) -> None: + """Pad to 250 and write in 25_000-byte chunks (vendor URB size).""" + msg = bytes(payload) + if len(msg) % 250: + msg += bytes(250 - (len(msg) % 250)) + if self.lcd_serial is not None: + self.lcd_serial.reset_input_buffer() + for i in range(0, len(msg), 25000): + self.WriteData(bytearray(msg[i : i + 25000])) + if self.lcd_serial is not None: + self.lcd_serial.flush() + + def _wait_113inch_full_png_success(self, timeout: float = 8.0) -> str: + deadline = time.time() + timeout + buf = b"" + while time.time() < deadline: + chunk = self.serial_read(1024) + if chunk: + buf += chunk + text = self._decode_hello(buf) + if "full_png_sucess" in text: + return text + return self._decode_hello(buf) + + def _maybe_query_status_113inch(self): + """Vendor polls 0xcf at ~1 Hz, never faster, and always before a 0xcc.""" + now = time.time() + if now - self._last_status_ts_113inch < 1.0: + return + self._send_command(Command.QUERY_STATUS, readsize=1024) + self._last_status_ts_113inch = now + + def _pack_113inch_c8(self, image: Image.Image) -> Image.Image: + """Pack a 440×1920 glass image into the vendor 1760×480 C8 canvas. + + Canvas pixel (cy, cx) is glass pixel (4*cy + cx//440, cx%440): each + canvas row packs 4 consecutive glass rows side by side. That is + exactly a (1920, 440, 3) -> (480, 1760, 3) numpy reshape, no + interpolation. A native 1760×480 image is sent unchanged; anything + else is resized to the true 440×1920 glass resolution first. + """ + if image.size == (Rev113Geometry.C8_WIDTH, Rev113Geometry.C8_HEIGHT): + return image + if image.mode not in ("RGB", "RGBA"): + image = image.convert("RGB") + if image.size != (Rev113Geometry.GLASS_WIDTH, Rev113Geometry.GLASS_HEIGHT): + image = image.resize((Rev113Geometry.GLASS_WIDTH, Rev113Geometry.GLASS_HEIGHT), Image.Resampling.LANCZOS) + glass = np.asarray(image.convert("RGB")) # (1920, 440, 3) + canvas = glass.reshape(Rev113Geometry.C8_HEIGHT, Rev113Geometry.C8_WIDTH, 3) # (480, 1760, 3) + return Image.fromarray(canvas, "RGB") + + def _map_113inch_view_to_glass( + self, x: int, y: int, width: int, height: int + ) -> Tuple[int, int, int, int]: + """Declared 440×1760 widget rect → true 440×1920 glass rect. + + x/width are unscaled (declared width == glass width). y/height scale + by 1920/1760 to fill the taller glass. + """ + view_h = self.display_height or Rev113Geometry.REV_113_HEIGHT + glass_h = Rev113Geometry.GLASS_HEIGHT + dx = x + dy = int(round(y * glass_h / view_h)) + dw = width + dh = max(1, int(round(height * glass_h / view_h))) + if dy >= glass_h: + dy = glass_h - 1 + if dy + dh > glass_h: + dh = glass_h - dy + return dx, dy, dw, dh + def _generate_full_image(self, image: Image.Image) -> bytes: - if self.sub_revision == SubRevision.REV_8INCH: + if self.sub_revision == SubRevision.REV_113INCH: + image = self._pack_113inch_c8(image) + elif self.sub_revision == SubRevision.REV_8INCH: # Switch landscape/portrait mode for 8" if self.orientation == Orientation.LANDSCAPE: image = image.rotate(270, expand=True) @@ -397,6 +599,9 @@ def _generate_full_image(self, image: Image.Image) -> bytes: def _generate_update_image( self, image: Image.Image, x: int, y: int, count: int, cmd: Optional[Command] = None ) -> Tuple[bytearray, bytearray]: + if self.sub_revision == SubRevision.REV_113INCH: + return self._generate_update_image_113inch(image, x, y, count, cmd) + x0, y0 = x, y if self.sub_revision == SubRevision.REV_8INCH: # Switch landscape/portrait mode for 8" @@ -442,13 +647,17 @@ def _generate_update_image( for h, line in enumerate(chunked(img_data, image.width * pixel_size)): if self.sub_revision == SubRevision.REV_8INCH: - # Switch landscape/portrait mode for 8" + # Switch landscape/portrait mode for 8" / 11.3" img_raw_data += int(((x0 + h) * self.display_width) + y0).to_bytes(3, "big") else: img_raw_data += int(((x0 + h) * self.display_height) + y0).to_bytes(3, "big") img_raw_data += int(image.width).to_bytes(2, "big") img_raw_data += line + if self.sub_revision == SubRevision.REV_113INCH and self._update_payload_needs_dummy(img_raw_data): + # Dummy visible-pixel field; shifts 0xEF69 off a packet boundary (PR #348). + img_raw_data += bytes((0x80, 0x00, 0x00, 0x00, 0x00)) + image_size = int(len(img_raw_data) + 2).to_bytes(3, "big") # The +2 is for the "ef69" that will be added later. # logger.debug("Render Count: {}".format(count)) @@ -465,3 +674,61 @@ def _generate_update_image( img_raw_data += b'\xef\x69' return img_raw_data, payload + + def _generate_update_image_113inch( + self, image: Image.Image, x: int, y: int, count: int, cmd: Optional[Command] = None + ) -> Tuple[bytearray, bytearray]: + """Encode a partial update into the 1760×480 C8 canvas. + + Callers use the declared 440×1760 portrait; x/y are scaled into the + true 440×1920 glass first (_map_113inch_view_to_glass). Each glass + row then decomposes into a canvas row + sub-slot (glass_row = + 4*canvas_row + sub, see class docstring): start = canvas_row*1760 + + sub*440 + x + col. Records are n=16 BGRA spans; image_size includes + the trailing ef69. + """ + x, y, dst_w, dst_h = self._map_113inch_view_to_glass(x, y, image.width, image.height) + if image.size != (dst_w, dst_h): + image = image.resize((dst_w, dst_h), Image.Resampling.LANCZOS) + img_data, _ = image_to_BGRA(image) + stride = image.width * 4 + span = 16 + img_raw_data = bytearray() + for h, line in enumerate(chunked(img_data, stride)): + glass_row = y + h + canvas_row, sub = divmod(glass_row, 4) + row_x0 = sub * Rev113Geometry.GLASS_WIDTH + x + col = 0 + while col < image.width: + n = min(span, image.width - col) + start = canvas_row * Rev113Geometry.C8_WIDTH + row_x0 + col + img_raw_data += int(start).to_bytes(3, "big") + img_raw_data += int(n).to_bytes(2, "big") + img_raw_data += line[col * 4 : (col + n) * 4] + col += n + img_raw_data += b"\xef\x69" + image_size = int(len(img_raw_data)).to_bytes(3, "big") + payload = bytearray() + if cmd: + payload.extend(cmd.value) + payload.extend(image_size) + payload.extend(Padding.NULL.value * 3) + payload.extend(count.to_bytes(4, "big")) + if len(img_raw_data) > 250: + img_raw_data = bytearray(b"\x00").join(chunked(bytes(img_raw_data), 249)) + return img_raw_data, payload + + @staticmethod + def _update_payload_needs_dummy(img_raw_data: bytearray) -> bool: + """True if ef69 would sit at the start or end of the final 250-byte packet.""" + body = bytes(img_raw_data) + if len(body) > 250: + body = b'\x00'.join(chunked(body, 249)) + candidate = body + b'\xef\x69' + n = len(candidate) if len(candidate) % 250 == 0 else 250 * ceil(len(candidate) / 250) + last = (candidate + bytes(n - len(candidate)))[-250:] + return ( + (last[:2] == b'\xef\x69' and set(last[2:]) <= {0}) + or last[-2:] == b'\xef\x69' + or (last[0] == 0x69 and last[1:5] == b'\x00\x00\x00\x00') + ) diff --git a/res/themes/Cyberpunk2077Vertical113/background.png b/res/themes/Cyberpunk2077Vertical113/background.png new file mode 100644 index 000000000..b252a24d6 Binary files /dev/null and b/res/themes/Cyberpunk2077Vertical113/background.png differ diff --git a/res/themes/Cyberpunk2077Vertical113/preview.png b/res/themes/Cyberpunk2077Vertical113/preview.png new file mode 100644 index 000000000..82783a87d Binary files /dev/null and b/res/themes/Cyberpunk2077Vertical113/preview.png differ diff --git a/res/themes/Cyberpunk2077Vertical113/theme.yaml b/res/themes/Cyberpunk2077Vertical113/theme.yaml new file mode 100644 index 000000000..3b0d9a3b7 --- /dev/null +++ b/res/themes/Cyberpunk2077Vertical113/theme.yaml @@ -0,0 +1,164 @@ +--- +author: "@mathoudebine" + +display: + DISPLAY_SIZE: 11.3" + DISPLAY_ORIENTATION: portrait + DISPLAY_RGB_LED: 255, 239, 47 + +static_images: + BACKGROUND: + PATH: background.png + X: 0 + Y: 0 + WIDTH: 440 + HEIGHT: 1920 + +static_text: + CPU: + TEXT: CPU + X: 315 + Y: 1273 + FONT: racespace/RACESPACEREGULAR-Extended.otf + FONT_SIZE: 22 + FONT_COLOR: 0, 0, 0 + BACKGROUND_IMAGE: background.png + GPU: + TEXT: GPU + X: 315 + Y: 1343 + FONT: racespace/RACESPACEREGULAR-Extended.otf + FONT_SIZE: 22 + FONT_COLOR: 0, 0, 0 + BACKGROUND_IMAGE: background.png + +STATS: + CPU: + PERCENTAGE: + INTERVAL: 1 + TEXT: + SHOW: True + SHOW_UNIT: False + X: 299 + Y: 1655 + WIDTH: 92 + HEIGHT: 44 + ANCHOR: mm + FONT: racespace/RACESPACEREGULAR-Extended.otf + FONT_SIZE: 37 + FONT_COLOR: 255, 255, 255 + BACKGROUND_IMAGE: background.png + GRAPH: + SHOW: True + X: 147 + Y: 1270 + WIDTH: 160 + HEIGHT: 24 + MIN_VALUE: 0 + MAX_VALUE: 100 + BAR_COLOR: 0, 0, 0 + BAR_OUTLINE: False + BACKGROUND_COLOR: 255, 255, 255 + TEMPERATURE: + INTERVAL: 5 + TEXT: + SHOW: True + SHOW_UNIT: False + X: 299 + Y: 1597 + WIDTH: 92 + HEIGHT: 44 + ANCHOR: mm + FONT: racespace/RACESPACEREGULAR-Extended.otf + FONT_SIZE: 37 + FONT_COLOR: 255, 255, 255 + BACKGROUND_IMAGE: background.png + FREQUENCY: + INTERVAL: 5 + TEXT: + SHOW: True + SHOW_UNIT: True + X: 224 + Y: 1300 + WIDTH: 119 + HEIGHT: 30 + ANCHOR: mm + FONT: racespace/RACESPACEREGULAR-Extended.otf + FONT_SIZE: 22 + FONT_COLOR: 0, 0, 0 + BACKGROUND_IMAGE: background.png + + GPU: + INTERVAL: 1 + PERCENTAGE: + GRAPH: + SHOW: True + X: 147 + Y: 1340 + WIDTH: 160 + HEIGHT: 24 + MIN_VALUE: 0 + MAX_VALUE: 100 + BAR_COLOR: 0, 0, 0 + BAR_OUTLINE: False + BACKGROUND_COLOR: 255, 255, 255 + TEXT: + SHOW: True + SHOW_UNIT: False + X: 299 + Y: 1779 + WIDTH: 92 + HEIGHT: 44 + ANCHOR: mm + FONT: racespace/RACESPACEREGULAR-Extended.otf + FONT_SIZE: 37 + FONT_COLOR: 255, 255, 255 + BACKGROUND_IMAGE: background.png + TEMPERATURE: + TEXT: + SHOW: True + SHOW_UNIT: False + X: 299 + Y: 1722 + WIDTH: 92 + HEIGHT: 44 + ANCHOR: mm + FONT: racespace/RACESPACEREGULAR-Extended.otf + FONT_SIZE: 37 + FONT_COLOR: 255, 255, 255 + BACKGROUND_IMAGE: background.png + FREQUENCY: + TEXT: + SHOW: True + SHOW_UNIT: True + X: 224 + Y: 1370 + WIDTH: 119 + HEIGHT: 30 + ANCHOR: mm + FONT: racespace/RACESPACEREGULAR-Extended.otf + FONT_SIZE: 22 + FONT_COLOR: 0, 0, 0 + BACKGROUND_IMAGE: background.png + + MEMORY: + INTERVAL: 5 + VIRTUAL: + USED: + SHOW: True + SHOW_UNIT: True + X: 149 + Y: 1435 + FONT: jetbrains-mono/JetBrainsMono-ExtraBold.ttf + FONT_SIZE: 28 + FONT_COLOR: 0, 0, 0 + BACKGROUND_IMAGE: background.png + PERCENT_TEXT: + SHOW: True + SHOW_UNIT: True + X: 133 + Y: 1499 + FONT: jetbrains-mono/JetBrainsMono-ExtraBold.ttf + FONT_SIZE: 28 + FONT_COLOR: 0, 0, 0 + BACKGROUND_IMAGE: background.png \ No newline at end of file diff --git a/res/themes/Gradient113/background.png b/res/themes/Gradient113/background.png new file mode 100644 index 000000000..889de3b4d Binary files /dev/null and b/res/themes/Gradient113/background.png differ diff --git a/res/themes/Gradient113/preview.png b/res/themes/Gradient113/preview.png new file mode 100644 index 000000000..dab1c7da3 Binary files /dev/null and b/res/themes/Gradient113/preview.png differ diff --git a/res/themes/Gradient113/theme.yaml b/res/themes/Gradient113/theme.yaml new file mode 100644 index 000000000..05dcff93d --- /dev/null +++ b/res/themes/Gradient113/theme.yaml @@ -0,0 +1,118 @@ +--- +author: "@mathoudebine" + +# 11.3" (ShinySnake G600 / TURZX) port of the Gradient theme. Native 440x1920 +# canvas -- background.png and every X/FONT_SIZE below are scaled by 440/480 +# from the original 8.8" Gradient theme (Y unchanged: both sizes are 1920 +# tall). See res/themes/Gradient46 for the precedent of a size-specific port +# rather than a shared/rescaled-at-runtime asset. +display: + DISPLAY_SIZE: 11.3" + DISPLAY_ORIENTATION: portrait + DISPLAY_RGB_LED: 226, 21, 103 + +static_images: + BACKGROUND: + PATH: background.png + X: 0 + Y: 0 + WIDTH: 440 + HEIGHT: 1920 +static_text: + DISK: + TEXT: DISK + X: 150 + Y: 1804 + FONT: roboto-mono/RobotoMono-Bold.ttf + FONT_SIZE: 59 + FONT_COLOR: 255, 255, 255 + BACKGROUND_IMAGE: background.png + +STATS: + CPU: + PERCENTAGE: + INTERVAL: 1 + TEXT: + SHOW: True + SHOW_UNIT: True + X: 73 + Y: 240 + FONT: roboto-mono/RobotoMono-Regular.ttf + FONT_SIZE: 119 + FONT_COLOR: 255, 255, 255 + BACKGROUND_IMAGE: background.png + TEMPERATURE: + INTERVAL: 1 + TEXT: + SHOW: True + SHOW_UNIT: True + X: 123 + Y: 154 + FONT: roboto-mono/RobotoMono-Regular.ttf + FONT_SIZE: 55 + FONT_COLOR: 255, 255, 255 + BACKGROUND_IMAGE: background.png + GPU: + INTERVAL: 1 + PERCENTAGE: + TEXT: + SHOW: True + SHOW_UNIT: True + X: 73 + Y: 720 + FONT: roboto-mono/RobotoMono-Regular.ttf + FONT_SIZE: 119 + FONT_COLOR: 255, 255, 255 + BACKGROUND_IMAGE: background.png + TEMPERATURE: + TEXT: + SHOW: True + SHOW_UNIT: True + X: 123 + Y: 635 + FONT: roboto-mono/RobotoMono-Regular.ttf + FONT_SIZE: 55 + FONT_COLOR: 255, 255, 255 + BACKGROUND_IMAGE: background.png + MEMORY: + INTERVAL: 5 + VIRTUAL: + USED: + SHOW: True + SHOW_UNIT: True + X: 134 + Y: 1120 + FONT: roboto-mono/RobotoMono-Regular.ttf + FONT_SIZE: 41 + FONT_COLOR: 255, 255, 255 + BACKGROUND_IMAGE: background.png + PERCENT_TEXT: + SHOW: True + SHOW_UNIT: True + X: 73 + Y: 1192 + FONT: roboto-mono/RobotoMono-Regular.ttf + FONT_SIZE: 119 + FONT_COLOR: 255, 255, 255 + BACKGROUND_IMAGE: background.png + DISK: + INTERVAL: 10 + USED: + TEXT: + SHOW: True + SHOW_UNIT: True + X: 134 + Y: 1590 + FONT: roboto-mono/RobotoMono-Regular.ttf + FONT_SIZE: 41 + FONT_COLOR: 255, 255, 255 + BACKGROUND_IMAGE: background.png + PERCENT_TEXT: + SHOW: True + SHOW_UNIT: True + X: 73 + Y: 1664 + FONT: roboto-mono/RobotoMono-Regular.ttf + FONT_SIZE: 119 + FONT_COLOR: 255, 255, 255 + BACKGROUND_IMAGE: background.png diff --git a/simple-program.py b/simple-program.py index 501cf9314..336ff7a86 100755 --- a/simple-program.py +++ b/simple-program.py @@ -82,7 +82,7 @@ def sighandler(signum, frame): logger.info("Selected Hardware Revision B (XuanFang screen 3.5\" version B / flagship)") lcd_comm = LcdCommRevB(com_port=COM_PORT, display_width=WIDTH, display_height=HEIGHT) elif REVISION == "C": - logger.info("Selected Hardware Revision C (Turing Smart Screen 5\")") + logger.info("Selected Hardware Revision C (Turing 2.1\"/2.8\"/5\"/8.8\"/11.3\")") lcd_comm = LcdCommRevC(com_port=COM_PORT, display_width=WIDTH, display_height=HEIGHT) elif REVISION == "D": logger.info("Selected Hardware Revision D (Kipye Qiye Smart Display 3.5\")") diff --git a/tests/library/lcd/test_lcd_comm_rev_c.py b/tests/library/lcd/test_lcd_comm_rev_c.py index 0f8d8be96..6046e3a8c 100644 --- a/tests/library/lcd/test_lcd_comm_rev_c.py +++ b/tests/library/lcd/test_lcd_comm_rev_c.py @@ -1,6 +1,6 @@ import unittest -from library.lcd.lcd_comm_rev_c import LcdCommRevC, Orientation +from library.lcd.lcd_comm_rev_c import Command, LcdCommRevC, Orientation, SubRevision from .serial_mock import new_testing_serial from .sample_image import generate_sample_image @@ -82,3 +82,161 @@ def test_display_pil_image_patch_reverse_landscape(self): lcd.DisplayPILImage(sample_img_landscape, x=10, y=20, image_width=100, image_height=200) lcd.expect_golden(self, "rev_c_display_pil_image_patch_reverse_landscape") + + +class TestLcdCommRevC113(unittest.TestCase): + def test_hello_24_char_id_sets_rom_90_and_113_revision(self): + lcd = MockedLcdCommRevC(display_width=480, display_height=1920) + lcd.lcd_serial.read.return_value = b"chs_113inch.dev1_rom1.90" + lcd.InitializeComm() + self.assertEqual(lcd.sub_revision, SubRevision.REV_113INCH) + self.assertEqual(lcd.rom_version, 90) + self.assertEqual(lcd.display_width, 440) + self.assertEqual(lcd.display_height, 1920) + + def test_hello_23_char_88inch_still_parses_rom_90(self): + lcd = MockedLcdCommRevC(display_width=480, display_height=1920) + lcd.lcd_serial.read.return_value = b"chs_88inch.dev1_rom1.90" + lcd.InitializeComm() + self.assertEqual(lcd.sub_revision, SubRevision.REV_8INCH) + self.assertEqual(lcd.rom_version, 90) + + def test_full_frame_uses_3390_size_word_twice(self): + from PIL import Image + + lcd = MockedLcdCommRevC(display_width=440, display_height=1920) + lcd.lcd_serial.read.return_value = b"chs_113inch.dev1_rom1.90" + lcd.InitializeComm() + lcd.lcd_serial.read.return_value = b"full_png_sucess" + lcd.DisplayPILImage(Image.new("RGB", (440, 1920), (255, 0, 0))) + writes = [args[0] for method, args, _ in lcd.lcd_serial.mock_calls if method == "write"] + marker_113 = bytes((0xC8, 0xEF, 0x69, 0x00, 0x33, 0x90)) + marker_88 = bytes((0xC8, 0xEF, 0x69, 0x00, 0x38, 0x40)) + c8 = [w for w in writes if w[:6] == marker_113] + self.assertEqual(len(c8), 2) + self.assertFalse(any(w[:6] == marker_88 for w in writes)) + self.assertTrue(all(len(w) % 250 == 0 for w in c8)) + + def test_full_frame_header_bypasses_a_real_update_queue(self): + # Regression test for the 2026-08-17 bug: _write_113inch_c8_body/ + # _wait_113inch_full_png_success always write/read the serial port directly, + # so if the header commands (PRE_UPDATE_BITMAP, START_DISPLAY_BITMAP, + # DISPLAY_BITMAP_113INCH) were queued instead of bypassing, a real + # update_queue (as main.py always supplies, unlike a standalone + # script or the other tests above that construct the driver with no + # queue at all) would leave them stuck unsent while the body raced + # ahead of its own header on the wire. That's what caused the + # intermittent "missing full_png_sucess" seen running a real theme. + from PIL import Image + import queue + + lcd = MockedLcdCommRevC(display_width=440, display_height=1920, update_queue=queue.Queue()) + lcd.lcd_serial.read.return_value = b"chs_113inch.dev1_rom1.90" + lcd.InitializeComm() + lcd.lcd_serial.read.return_value = b"full_png_sucess" + lcd.DisplayPILImage(Image.new("RGB", (440, 1920), (255, 0, 0))) + writes = [args[0] for method, args, _ in lcd.lcd_serial.mock_calls if method == "write"] + pre_update_marker = bytes(Command.PRE_UPDATE_BITMAP.value) + c8_marker = bytes((0xC8, 0xEF, 0x69, 0x00, 0x33, 0x90)) + self.assertTrue( + any(w[:len(pre_update_marker)] == pre_update_marker for w in writes), + "PRE_UPDATE_BITMAP never reached the wire -- stuck in the queue", + ) + self.assertEqual(len([w for w in writes if w[:6] == c8_marker]), 2) + + def test_partial_update_is_cc_after_status_with_incrementing_count(self): + from PIL import Image + + from library.lcd.lcd_comm_rev_c import Count + + Count.Start = 0 + lcd = MockedLcdCommRevC(display_width=440, display_height=1920) + lcd.lcd_serial.read.return_value = b"chs_113inch.dev1_rom1.90" + lcd.InitializeComm() + lcd.SetOrientation(Orientation.REVERSE_PORTRAIT) + lcd.DisplayPILImage(Image.new("RGB", (40, 20), (255, 255, 0)), x=10, y=20) + writes = [args[0] for method, args, _ in lcd.lcd_serial.mock_calls if method == "write"] + statuses = [w for w in writes if w[:1] == b"\xcf"] + ccs = [w for w in writes if w[:3] == bytes((0xCC, 0xEF, 0x69))] + self.assertGreaterEqual(len(statuses), 1) + self.assertGreaterEqual(len(ccs), 1) + # count field is bytes 10:14 of the 0xcc header, starts at 0 after hello + self.assertEqual(int.from_bytes(ccs[0][10:14], "big"), 0) + + def test_pack_reshapes_440x1920_glass_into_1760x480_canvas(self): + # Canvas pixel (cy, cx) is glass pixel (4*cy + cx//440, cx%440): + # each canvas row packs 4 glass rows side by side. Confirmed on + # hardware (not just derived) via tools/reshape_test.py in the + # shinysnake-g600 repo: a native 440x1920 card reshaped this way + # showed correct edges (magenta far-left, cyan far-right) and full + # vertical resolution, where the old 480-wide-tile packing put + # cyan near the LEFT edge (wraparound) and only lit 1 glass row in 4. + from PIL import Image + + lcd = MockedLcdCommRevC(display_width=440, display_height=1920) + lcd.lcd_serial.read.return_value = b"chs_113inch.dev1_rom1.90" + lcd.InitializeComm() + glass = Image.new("RGB", (440, 1920), (0, 0, 0)) + glass.putpixel((0, 0), (255, 0, 0)) # glass row 0, col 0 + glass.putpixel((439, 0), (0, 255, 0)) # glass row 0, col 439 (last column) + glass.putpixel((0, 1), (0, 0, 255)) # glass row 1 -> same canvas row, sub=1 + glass.putpixel((0, 4), (255, 255, 0)) # glass row 4 -> canvas row 1, sub=0 + packed = lcd._pack_113inch_c8(glass) + self.assertEqual(packed.size, (1760, 480)) + self.assertEqual(packed.getpixel((0, 0)), (255, 0, 0)) # canvas(0,0) + self.assertEqual(packed.getpixel((439, 0)), (0, 255, 0)) # canvas(0,439) + self.assertEqual(packed.getpixel((440, 0)), (0, 0, 255)) # canvas(0,440) = glass row1,col0 + self.assertEqual(packed.getpixel((0, 1)), (255, 255, 0)) # canvas(1,0) = glass row4,col0 + native = Image.new("RGB", (1760, 480), (0, 255, 0)) + self.assertIs(lcd._pack_113inch_c8(native), native) + + def test_borrowed_8_8inch_background_scales_instead_of_cropping(self): + # Regression test for 2026-08-18: a borrowed 8.8" theme's full + # background (480x1920 -- matches its declared WIDTH/HEIGHT exactly, + # so DisplayBitmap never resizes it) used to fall through to the + # generic crop path shared by every Rev C size, which hard-crops to + # our declared width (440) *before* _pack_113inch_c8 ever runs -- + # silently discarding the rightmost 40px of real content instead of + # scaling it in. Confirmed on the actual Gradient theme: its + # gauge-ring content reached x=447 of 480 and was being clipped by + # the x=439 crop boundary. + # + # Goes through the real DisplayPILImage entry point (not + # _pack_113inch_c8 directly) and inspects what image actually + # reached the packer, so this fails again if the whitelist entry + # for (480, GLASS_HEIGHT) that fixes the dispatch is ever reverted + # -- calling the packer directly wouldn't catch that, since a + # pre-cropped 440x1920 image looks like a no-op resize to it. + from PIL import Image + + lcd = MockedLcdCommRevC(display_width=440, display_height=1920) + lcd.lcd_serial.read.return_value = b"chs_113inch.dev1_rom1.90" + lcd.InitializeComm() + lcd.lcd_serial.read.return_value = b"full_png_sucess" + + seen_sizes = [] + original_pack = lcd._pack_113inch_c8 + lcd._pack_113inch_c8 = lambda image: (seen_sizes.append(image.size), original_pack(image))[1] + + bg = Image.new("RGB", (480, 1920), (0, 0, 0)) + lcd.DisplayPILImage(bg, 0, 0, 480, 1920) + + self.assertEqual( + seen_sizes, [(480, 1920)], + "packer received a pre-cropped image instead of the original 480-wide one -- " + "content was already lost before it could be scaled", + ) + + def test_map_view_to_glass_scales_portrait_y(self): + # Declared height == glass height (1920) -- see class docstring on + # why this must be 1:1: an 8.8" theme borrowed via the size picker + # (also declared 1920 tall) crashed a DisplayText bounds assert when + # the declared height was 1760 while the background path already + # filled the full 1920-tall glass regardless. + lcd = MockedLcdCommRevC(display_width=440, display_height=1920) + lcd.lcd_serial.read.return_value = b"chs_113inch.dev1_rom1.90" + lcd.InitializeComm() + self.assertEqual(lcd._map_113inch_view_to_glass(10, 0, 40, 1920), (10, 0, 40, 1920)) + self.assertEqual(lcd._map_113inch_view_to_glass(10, 960, 40, 192), (10, 960, 40, 192)) + # Clamp: a widget that would run past the bottom edge gets trimmed. + self.assertEqual(lcd._map_113inch_view_to_glass(10, 1900, 40, 50), (10, 1900, 40, 20))