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