|
| 1 | +#!/usr/bin/env python3 |
| 2 | +# A simple Python manager for "Turing Smart Screen" 3.5" IPS USB-C display |
| 3 | +# https://github.com/mathoudebine/turing-smart-screen-python |
| 4 | + |
| 5 | +import os |
| 6 | +import signal |
| 7 | +import struct |
| 8 | +from datetime import datetime |
| 9 | +from time import sleep |
| 10 | + |
| 11 | +import serial # Install pyserial : pip install pyserial |
| 12 | +from PIL import Image, ImageDraw, ImageFont # Install PIL or Pillow |
| 13 | + |
| 14 | +# Set your COM port e.g. COM3 for Windows, /dev/ttyACM0 for Linux... |
| 15 | +# COM_PORT = "/dev/ttyACM0" |
| 16 | +# COM_PORT = "COM5" |
| 17 | +# MacOS COM port: |
| 18 | +COM_PORT = '/dev/cu.usbmodem2017_2_251' |
| 19 | + |
| 20 | +# The new device has a serial number of '2017-2-25' |
| 21 | + |
| 22 | +DISPLAY_WIDTH = 320 |
| 23 | +DISPLAY_HEIGHT = 480 |
| 24 | + |
| 25 | + |
| 26 | +class TuringError(Exception): |
| 27 | + pass |
| 28 | + |
| 29 | + |
| 30 | +class Command: |
| 31 | + # New protocol (10 byte packets, framed with the command, 8 data bytes inside) |
| 32 | + HELLO = 0xCA |
| 33 | + ORIENTATION = 0xCB |
| 34 | + ORIENTATION_PORTRAIT = 0 |
| 35 | + ORIENTATION_LANDSCAPE = 1 |
| 36 | + # The device seems to start in PORTRAIT, with the row ordering reversed from |
| 37 | + # the ORIENTATION_PORTRAIT setting. It is not clear how to restore the ordering |
| 38 | + # to the reset configuration. |
| 39 | + DISPLAY_BITMAP = 0xCC |
| 40 | + LIGHTING = 0xCD |
| 41 | + SET_BRIGHTNESS = 0xCE |
| 42 | + |
| 43 | + |
| 44 | +def SendCommand(ser: serial.Serial, cmd: int, payload=None): |
| 45 | + if payload is None: |
| 46 | + payload = [0] * 8 |
| 47 | + elif len(payload) < 8: |
| 48 | + payload = list(payload) + [0] * (8 - len(payload)) |
| 49 | + |
| 50 | + byteBuffer = bytearray(10) |
| 51 | + byteBuffer[0] = cmd |
| 52 | + byteBuffer[1] = payload[0] |
| 53 | + byteBuffer[2] = payload[1] |
| 54 | + byteBuffer[3] = payload[2] |
| 55 | + byteBuffer[4] = payload[3] |
| 56 | + byteBuffer[5] = payload[4] |
| 57 | + byteBuffer[6] = payload[5] |
| 58 | + byteBuffer[7] = payload[6] |
| 59 | + byteBuffer[8] = payload[7] |
| 60 | + byteBuffer[9] = cmd |
| 61 | + print("Sending %r" % (byteBuffer,)) |
| 62 | + ser.write(bytes(byteBuffer)) |
| 63 | + |
| 64 | + |
| 65 | +def Hello(ser: serial.Serial): |
| 66 | + hello = [ord('H'), ord('E'), ord('L'), ord('L'), ord('O')] |
| 67 | + SendCommand(ser, Command.HELLO, payload=hello) |
| 68 | + response = ser.read(10) |
| 69 | + if len(response) != 10: |
| 70 | + raise TuringError("Device not recognised (short response to HELLO)") |
| 71 | + if response[0] != Command.HELLO or response[-1] != Command.HELLO: |
| 72 | + raise TuringError("Device not recognised (bad framing)") |
| 73 | + if [x for x in response[1:6]] != hello: |
| 74 | + raise TuringError("Device not recognised (No HELLO; got %r)" % (response[1:6],)) |
| 75 | + # The HELLO response here is followed by: |
| 76 | + # 0x0A, 0x12, 0x00 |
| 77 | + # It is not clear what these might be. |
| 78 | + # It would be handy if these were a version number, or a set of capability |
| 79 | + # flags. The 0x0A=10 being version 10 or 0.10, and the 0x12 being the size or the |
| 80 | + # indication that a backlight is present, would be nice. But that's guessing |
| 81 | + # based on how I'd do it. |
| 82 | + |
| 83 | + |
| 84 | +def Orientation(ser: serial.Serial, state: int): |
| 85 | + print("Orientation: %r" % (state,)) |
| 86 | + SendCommand(ser, Command.ORIENTATION, payload=[state]) |
| 87 | + |
| 88 | + |
| 89 | +def SetLighting(ser: serial.Serial, red: int, green: int, blue: int): |
| 90 | + print("Lighting: %i, %i, %i" % (red, green, blue)) |
| 91 | + assert red < 256, 'Red lighting must be < 256' |
| 92 | + assert green < 256, 'Green lighting must be < 256' |
| 93 | + assert blue < 256, 'Blue lighting must be < 256' |
| 94 | + SendCommand(ser, Command.LIGHTING, payload=[red, green, blue]) |
| 95 | + |
| 96 | + |
| 97 | +def SetBrightness(ser: serial.Serial, level: int): |
| 98 | + # Level : 0 (brightest) - 255 (darkest) |
| 99 | + assert 255 >= level >= 0, 'Brightness level must be [0-255]' |
| 100 | + # New protocol has 255 as the brightest, and 0 as off. |
| 101 | + SendCommand(ser, Command.SET_BRIGHTNESS, payload=[255-level]) |
| 102 | + |
| 103 | + |
| 104 | +def DisplayPILImage(ser: serial.Serial, image: Image, x: int, y: int): |
| 105 | + image_height = image.size[1] |
| 106 | + image_width = image.size[0] |
| 107 | + |
| 108 | + assert image_height > 0, 'Image width must be > 0' |
| 109 | + assert image_width > 0, 'Image height must be > 0' |
| 110 | + |
| 111 | + (x0, y0) = (x, y) |
| 112 | + (x1, y1) = (x + image_width - 1, y + image_height - 1) |
| 113 | + |
| 114 | + SendCommand(ser, Command.DISPLAY_BITMAP, |
| 115 | + payload=[(x0>>8) & 255, x0 & 255, |
| 116 | + (y0>>8) & 255, y0 & 255, |
| 117 | + (x1>>8) & 255, x1 & 255, |
| 118 | + (y1>>8) & 255, y1 & 255]) |
| 119 | + |
| 120 | + pix = image.load() |
| 121 | + line = bytes() |
| 122 | + for h in range(image_height): |
| 123 | + for w in range(image_width): |
| 124 | + R = pix[w, h][0] >> 3 |
| 125 | + G = pix[w, h][1] >> 2 |
| 126 | + B = pix[w, h][2] >> 3 |
| 127 | + |
| 128 | + # Original: 0bRRRRRGGGGGGBBBBB |
| 129 | + # fedcba9876543210 |
| 130 | + # New: 0bgggBBBBBRRRRRGGG |
| 131 | + # That is... |
| 132 | + # High 3 bits of green in b0-b2 |
| 133 | + # Low 3 bits of green in b13-b15 |
| 134 | + # Red 5 bits in b3-b7 |
| 135 | + # Blue 5 bits in b8-b12 |
| 136 | + rgb = (B << 8) | (G>>3) | ((G&7)<<13) | (R<<3) |
| 137 | + line += struct.pack('H', rgb) |
| 138 | + |
| 139 | + # Send image data by multiple of DISPLAY_WIDTH bytes |
| 140 | + if len(line) >= DISPLAY_WIDTH * 8: |
| 141 | + ser.write(line) |
| 142 | + line = bytes() |
| 143 | + |
| 144 | + # Write last line if needed |
| 145 | + if len(line) > 0: |
| 146 | + ser.write(line) |
| 147 | + |
| 148 | + sleep(0.01) # Wait 10 ms after picture display |
| 149 | + |
| 150 | + |
| 151 | +def DisplayBitmap(ser: serial.Serial, bitmap_path: str, x=0, y=0): |
| 152 | + image = Image.open(bitmap_path) |
| 153 | + DisplayPILImage(ser, image, x, y) |
| 154 | + |
| 155 | + |
| 156 | +def DisplayText(ser: serial.Serial, text: str, x=0, y=0, |
| 157 | + font="roboto/Roboto-Regular.ttf", |
| 158 | + font_size=20, |
| 159 | + font_color=(0, 0, 0), |
| 160 | + background_color=(255, 255, 255), |
| 161 | + background_image: str = None): |
| 162 | + # Convert text to bitmap using PIL and display it |
| 163 | + # Provide the background image path to display text with transparent background |
| 164 | + |
| 165 | + assert len(text) > 0, 'Text must not be empty' |
| 166 | + assert font_size > 0, "Font size must be > 0" |
| 167 | + |
| 168 | + if background_image is None: |
| 169 | + # A text bitmap is created with max width/height by default : text with solid background |
| 170 | + text_image = Image.new('RGB', (DISPLAY_WIDTH, DISPLAY_HEIGHT), background_color) |
| 171 | + else: |
| 172 | + # The text bitmap is created from provided background image : text with transparent background |
| 173 | + text_image = Image.open(background_image) |
| 174 | + |
| 175 | + # Draw text with specified color & font (also crop if text overflows display) |
| 176 | + font = ImageFont.truetype("./res/fonts/" + font, font_size) |
| 177 | + d = ImageDraw.Draw(text_image) |
| 178 | + d.text((x, y), text, font=font, fill=font_color) |
| 179 | + |
| 180 | + # Crop text bitmap to keep only the text |
| 181 | + left, top, text_width, text_height = d.textbbox((0,0), text, font=font) |
| 182 | + text_image = text_image.crop(box=(x, y, min(x + text_width, DISPLAY_WIDTH), min(y + text_height, DISPLAY_HEIGHT))) |
| 183 | + |
| 184 | + DisplayPILImage(ser, text_image, x, y) |
| 185 | + |
| 186 | + |
| 187 | +def DisplayProgressBar(ser: serial.Serial, x: int, y: int, width: int, height: int, min_value=0, max_value=100, |
| 188 | + value=50, |
| 189 | + bar_color=(0, 0, 0), |
| 190 | + bar_outline=True, |
| 191 | + background_color=(255, 255, 255), |
| 192 | + background_image: str = None): |
| 193 | + # Generate a progress bar and display it |
| 194 | + # Provide the background image path to display progress bar with transparent background |
| 195 | + |
| 196 | + assert x + width <= DISPLAY_WIDTH, 'Progress bar width exceeds display width' |
| 197 | + assert y + height <= DISPLAY_HEIGHT, 'Progress bar height exceeds display height' |
| 198 | + assert min_value <= value <= max_value, 'Progress bar value shall be between min and max' |
| 199 | + |
| 200 | + if background_image is None: |
| 201 | + # A bitmap is created with solid background |
| 202 | + bar_image = Image.new('RGB', (width, height), background_color) |
| 203 | + else: |
| 204 | + # A bitmap is created from provided background image |
| 205 | + bar_image = Image.open(background_image) |
| 206 | + |
| 207 | + # Crop bitmap to keep only the progress bar background |
| 208 | + bar_image = bar_image.crop(box=(x, y, x + width, y + height)) |
| 209 | + |
| 210 | + # Draw progress bar |
| 211 | + bar_filled_width = value / (max_value - min_value) * width |
| 212 | + draw = ImageDraw.Draw(bar_image) |
| 213 | + draw.rectangle([0, 0, bar_filled_width-1, height-1], fill=bar_color, outline=bar_color) |
| 214 | + |
| 215 | + if bar_outline: |
| 216 | + # Draw outline |
| 217 | + draw.rectangle([0, 0, width-1, height-1], fill=None, outline=bar_color) |
| 218 | + |
| 219 | + DisplayPILImage(ser, bar_image, x, y) |
| 220 | + |
| 221 | + |
| 222 | +stop = False |
| 223 | + |
| 224 | +if __name__ == "__main__": |
| 225 | + |
| 226 | + def sighandler(signum, frame): |
| 227 | + global stop |
| 228 | + stop = True |
| 229 | + |
| 230 | + |
| 231 | + # Set the signal handlers, to send a complete frame to the LCD before exit |
| 232 | + signal.signal(signal.SIGINT, sighandler) |
| 233 | + signal.signal(signal.SIGTERM, sighandler) |
| 234 | + is_posix = os.name == 'posix' |
| 235 | + if is_posix: |
| 236 | + signal.signal(signal.SIGQUIT, sighandler) |
| 237 | + |
| 238 | + # Do not change COM port settings unless you know what you are doing |
| 239 | + lcd_comm = serial.Serial(COM_PORT, 115200, timeout=1, rtscts=1) |
| 240 | + |
| 241 | + # Hello! to check this is the right device |
| 242 | + Hello(lcd_comm) |
| 243 | + |
| 244 | + # Data orientation |
| 245 | + Orientation(lcd_comm, Command.ORIENTATION_PORTRAIT) |
| 246 | + |
| 247 | + # Set brightness to max value |
| 248 | + SetBrightness(lcd_comm, 0) |
| 249 | + |
| 250 | + # Lighting (a purple) |
| 251 | + SetLighting(lcd_comm, 128, 50, 112) |
| 252 | + |
| 253 | + # Display sample picture |
| 254 | + DisplayBitmap(lcd_comm, "res/example.png") |
| 255 | + |
| 256 | + # Display sample text |
| 257 | + DisplayText(lcd_comm, "Basic text", 50, 100) |
| 258 | + |
| 259 | + # Display custom text with solid background |
| 260 | + DisplayText(lcd_comm, "Custom italic text", 5, 150, |
| 261 | + font="roboto/Roboto-Italic.ttf", |
| 262 | + font_size=30, |
| 263 | + font_color=(0, 0, 255), |
| 264 | + background_color=(255, 255, 0)) |
| 265 | + |
| 266 | + # Display custom text with transparent background |
| 267 | + DisplayText(lcd_comm, "Transparent bold text", 5, 300, |
| 268 | + font="geforce/GeForce-Bold.ttf", |
| 269 | + font_size=30, |
| 270 | + font_color=(255, 255, 255), |
| 271 | + background_image="res/example.png") |
| 272 | + |
| 273 | + # Display text that overflows |
| 274 | + DisplayText(lcd_comm, "Text overflow!", 5, 430, |
| 275 | + font="roboto/Roboto-Bold.ttf", |
| 276 | + font_size=60, |
| 277 | + font_color=(255, 255, 255), |
| 278 | + background_image="res/example.png") |
| 279 | + |
| 280 | + # Display the current time and some progress bars as fast as possible |
| 281 | + bar_value = 0 |
| 282 | + while not stop: |
| 283 | + DisplayText(lcd_comm, str(datetime.now().time()), 160, 2, |
| 284 | + font="roboto/Roboto-Bold.ttf", |
| 285 | + font_size=20, |
| 286 | + font_color=(255, 0, 0), |
| 287 | + background_image="res/example.png") |
| 288 | + |
| 289 | + DisplayProgressBar(lcd_comm, 10, 40, |
| 290 | + width=140, height=30, |
| 291 | + min_value=0, max_value=100, value=bar_value, |
| 292 | + bar_color=(255, 255, 0), bar_outline=True, |
| 293 | + background_image="res/example.png") |
| 294 | + |
| 295 | + DisplayProgressBar(lcd_comm, 160, 40, |
| 296 | + width=140, height=30, |
| 297 | + min_value=0, max_value=19, value=bar_value % 20, |
| 298 | + bar_color=(0, 255, 0), bar_outline=False, |
| 299 | + background_image="res/example.png") |
| 300 | + |
| 301 | + bar_value = (bar_value + 2) % 101 |
| 302 | + |
| 303 | + lcd_comm.close() |
0 commit comments