From bca285c4782b78b79e07f990a2b56eee2059ffe2 Mon Sep 17 00:00:00 2001 From: Moises Lopez Date: Mon, 16 Mar 2026 22:02:03 -1000 Subject: [PATCH 1/2] Add support for StreamDeck Plus XL and update product IDs --- src/StreamDeck/DeviceManager.py | 2 + src/StreamDeck/Devices/StreamDeckPlusXL.py | 236 +++++++++++++++++++++ src/StreamDeck/ProductIDs.py | 1 + 3 files changed, 239 insertions(+) create mode 100644 src/StreamDeck/Devices/StreamDeckPlusXL.py diff --git a/src/StreamDeck/DeviceManager.py b/src/StreamDeck/DeviceManager.py index f159ca08..794bf18e 100644 --- a/src/StreamDeck/DeviceManager.py +++ b/src/StreamDeck/DeviceManager.py @@ -14,6 +14,7 @@ from .Devices.StreamDeckPedal import StreamDeckPedal from .Devices.StreamDeckStudio import StreamDeckStudio from .Devices.StreamDeckPlus import StreamDeckPlus +from .Devices.StreamDeckPlusXL import StreamDeckPlusXL from .Transport import Transport from .Transport.Dummy import Dummy from .Transport.LibUSBHIDAPI import LibUSBHIDAPI @@ -113,6 +114,7 @@ def enumerate(self) -> list[StreamDeck]: (USBVendorIDs.USB_VID_ELGATO, USBProductIDs.USB_PID_STREAMDECK_XL_V2_MODULE, StreamDeckXL), (USBVendorIDs.USB_VID_ELGATO, USBProductIDs.USB_PID_STREAMDECK_STUDIO, StreamDeckStudio), (USBVendorIDs.USB_VID_ELGATO, USBProductIDs.USB_PID_STREAMDECK_PLUS, StreamDeckPlus), + (USBVendorIDs.USB_VID_ELGATO, USBProductIDs.USB_PID_STREAMDECK_PLUS_XL, StreamDeckPlusXL), ] streamdecks = list() diff --git a/src/StreamDeck/Devices/StreamDeckPlusXL.py b/src/StreamDeck/Devices/StreamDeckPlusXL.py new file mode 100644 index 00000000..bc0c87cb --- /dev/null +++ b/src/StreamDeck/Devices/StreamDeckPlusXL.py @@ -0,0 +1,236 @@ +# Python Stream Deck Library +# Released under the MIT license +# +# dean [at] fourwalledcubicle [dot] com +# www.fourwalledcubicle.com +# + +from .StreamDeck import StreamDeck, ControlType, DialEventType, TouchscreenEventType +from ..ImageHelpers import PILHelper + + +def _dials_rotation_transform(value): + if value < 0x80: + # Clockwise rotation + return value + else: + # Counterclockwise rotation + return -(0x100 - value) + + +class StreamDeckPlusXL(StreamDeck): + KEY_COUNT = 36 + KEY_COLS = 9 + KEY_ROWS = 4 + + DIAL_COUNT = 6 + + KEY_PIXEL_WIDTH = 112 + KEY_PIXEL_HEIGHT = 112 + KEY_IMAGE_FORMAT = "JPEG" + KEY_FLIP = (False, False) + KEY_ROTATION = 0 + + DECK_TYPE = "Stream Deck + XL" + DECK_VISUAL = True + DECK_TOUCH = True + + TOUCHSCREEN_PIXEL_HEIGHT = 100 + TOUCHSCREEN_PIXEL_WIDTH = 1200 + TOUCHSCREEN_IMAGE_FORMAT = "JPEG" + TOUCHSCREEN_FLIP = (False, False) + TOUCHSCREEN_ROTATION = 0 + + _INPUT_REPORT_LENGTH = 64 + + _IMG_PACKET_LEN = 1024 + + _KEY_PACKET_HEADER = 8 + _LCD_PACKET_HEADER = 16 + + _KEY_PACKET_PAYLOAD_LEN = _IMG_PACKET_LEN - _KEY_PACKET_HEADER + _LCD_PACKET_PAYLOAD_LEN = _IMG_PACKET_LEN - _LCD_PACKET_HEADER + + _DIAL_EVENT_TRANSFORM = { + DialEventType.TURN: _dials_rotation_transform, + DialEventType.PUSH: bool, + } + + def __init__(self, device): + super().__init__(device) + self.BLANK_KEY_IMAGE = PILHelper.to_native_key_format( + self, PILHelper.create_key_image(self, "black") + ) + self.BLANK_TOUCHSCREEN_IMAGE = PILHelper.to_native_touchscreen_format( + self, PILHelper.create_touchscreen_image(self, "black") + ) + + def _reset_key_stream(self): + payload = bytearray(self._IMG_PACKET_LEN) + payload[0] = 0x02 + self.device.write(payload) + + def reset(self): + payload = bytearray(32) + payload[0:2] = [0x03, 0x02] + self.device.write_feature(payload) + + def _read_control_states(self): + states = self.device.read(self._INPUT_REPORT_LENGTH) + + if states is None: + return None + + states = states[1:] + + if states[0] == 0x00: # Key Event + new_key_states = [bool(s) for s in states[3:3 + self.KEY_COUNT]] + + return { + ControlType.KEY: new_key_states + } + elif states[0] == 0x02: # Touchscreen Event + if states[3] == 1: + event_type = TouchscreenEventType.SHORT + elif states[3] == 2: + event_type = TouchscreenEventType.LONG + elif states[3] == 3: + event_type = TouchscreenEventType.DRAG + else: + return None + + value = { + 'x': (states[6] << 8) + states[5], + 'y': (states[8] << 8) + states[7] + } + + if event_type == TouchscreenEventType.DRAG: + value["x_out"] = (states[10] << 8) + states[9] + value["y_out"] = (states[12] << 8) + states[11] + + return { + ControlType.TOUCHSCREEN: (event_type, value), + } + elif states[0] == 0x03: # Dial Event + if states[3] == 0x01: + event_type = DialEventType.TURN + elif states[3] == 0x00: + event_type = DialEventType.PUSH + else: + return None + + values = [self._DIAL_EVENT_TRANSFORM[event_type](s) for s in states[4:4 + self.DIAL_COUNT]] + + return { + ControlType.DIAL: { + event_type: values, + } + } + + def set_brightness(self, percent): + if isinstance(percent, float): + percent = int(100.0 * percent) + + percent = min(max(percent, 0), 100) + + payload = bytearray(32) + payload[0:3] = [0x03, 0x08, percent] + + self.device.write_feature(payload) + + def get_serial_number(self): + serial = self.device.read_feature(0x06, 32) + return self._extract_string(serial[2:]) + + def get_firmware_version(self): + version = self.device.read_feature(0x05, 32) + return self._extract_string(version[6:]) + + def set_key_image(self, key, image): + if min(max(key, 0), self.KEY_COUNT) != key: + raise IndexError("Invalid key index {}.".format(key)) + + image = bytes(image or self.BLANK_KEY_IMAGE) + + page_number = 0 + bytes_remaining = len(image) + while bytes_remaining > 0: + this_length = min(bytes_remaining, self._KEY_PACKET_PAYLOAD_LEN) + + header = [ + 0x02, # 0 + 0x07, # 1 + key & 0xff, # 2 key_index + 1 if this_length == bytes_remaining else 0, # 3 is_last + this_length & 0xff, # 4 bytecount low byte + (this_length >> 8) & 0xff, # 5 bytecount high byte + page_number & 0xff, # 6 pagenumber low byte + (page_number >> 8) & 0xff, # 7 pagenumber high byte + ] + + bytes_sent = page_number * (self._KEY_PACKET_PAYLOAD_LEN) + payload = bytes(header) + image[bytes_sent:bytes_sent + this_length] + padding = bytearray(self._IMG_PACKET_LEN - len(payload)) + self.device.write(payload + padding) + bytes_remaining = bytes_remaining - this_length + page_number = page_number + 1 + + def set_touchscreen_image(self, image, x_pos=0, y_pos=0, width=0, height=0): + if not image: + image = self.BLANK_TOUCHSCREEN_IMAGE + x_pos = 0 + y_pos = 0 + width = self.TOUCHSCREEN_PIXEL_WIDTH + height = self.TOUCHSCREEN_PIXEL_HEIGHT + + if min(max(x_pos, 0), self.TOUCHSCREEN_PIXEL_WIDTH) != x_pos: + raise IndexError("Invalid x position {}.".format(x_pos)) + + if min(max(y_pos, 0), self.TOUCHSCREEN_PIXEL_HEIGHT) != y_pos: + raise IndexError("Invalid y position {}.".format(y_pos)) + + if min(max(width, 1), self.TOUCHSCREEN_PIXEL_WIDTH - x_pos) != width: + raise IndexError("Invalid draw width {}.".format(width)) + + if min(max(height, 1), self.TOUCHSCREEN_PIXEL_HEIGHT - y_pos) != height: + raise IndexError("Invalid draw height {}.".format(height)) + + image = bytes(image) + + page_number = 0 + bytes_remaining = len(image) + while bytes_remaining > 0: + this_length = min(bytes_remaining, self._LCD_PACKET_PAYLOAD_LEN) + bytes_sent = page_number * self._LCD_PACKET_PAYLOAD_LEN + + header = [ + 0x02, # 0 + 0x0c, # 1 + x_pos & 0xff, # 2 xpos low byte + (x_pos >> 8) & 0xff, # 3 xpos high byte + y_pos & 0xff, # 4 ypos low byte + (y_pos >> 8) & 0xff, # 5 ypos high byte + width & 0xff, # 6 width low byte + (width >> 8) & 0xff, # 7 width high byte + height & 0xff, # 8 height low byte + (height >> 8) & 0xff, # 9 height high byte + 1 if this_length == bytes_remaining else 0, # 10 is the last report? + page_number & 0xff, # 11 pagenumber low byte + (page_number >> 8) & 0xff, # 12 pagenumber high byte + this_length & 0xff, # 13 bytecount low byte + (this_length >> 8) & 0xff, # 14 bytecount high byte + 0x00, # 15 padding + ] + + payload = bytes(header) + image[bytes_sent:bytes_sent + this_length] + padding = bytearray(self._IMG_PACKET_LEN - len(payload)) + self.device.write(payload + padding) + + bytes_remaining = bytes_remaining - this_length + page_number = page_number + 1 + + def set_key_color(self, key, r, g, b): + pass + + def set_screen_image(self, image): + pass diff --git a/src/StreamDeck/ProductIDs.py b/src/StreamDeck/ProductIDs.py index fe8b38d3..4dac4ecf 100644 --- a/src/StreamDeck/ProductIDs.py +++ b/src/StreamDeck/ProductIDs.py @@ -35,3 +35,4 @@ class USBProductIDs: USB_PID_STREAMDECK_XL_V2 = 0x008f USB_PID_STREAMDECK_STUDIO = 0x00aa USB_PID_STREAMDECK_XL_V2_MODULE = 0x00ba + USB_PID_STREAMDECK_PLUS_XL = 0x00c6 From 2117412c4c9a6a5914d46d2b605ab39dced23f39 Mon Sep 17 00:00:00 2001 From: Moises Lopez Date: Tue, 17 Mar 2026 12:32:30 -1000 Subject: [PATCH 2/2] Fix rotation handling for touchscreen images on StreamDeck Plus XL --- src/StreamDeck/Devices/StreamDeckPlusXL.py | 36 ++++++++++++++++------ 1 file changed, 26 insertions(+), 10 deletions(-) diff --git a/src/StreamDeck/Devices/StreamDeckPlusXL.py b/src/StreamDeck/Devices/StreamDeckPlusXL.py index bc0c87cb..6a7936ce 100644 --- a/src/StreamDeck/Devices/StreamDeckPlusXL.py +++ b/src/StreamDeck/Devices/StreamDeckPlusXL.py @@ -29,7 +29,7 @@ class StreamDeckPlusXL(StreamDeck): KEY_PIXEL_HEIGHT = 112 KEY_IMAGE_FORMAT = "JPEG" KEY_FLIP = (False, False) - KEY_ROTATION = 0 + KEY_ROTATION = 90 DECK_TYPE = "Stream Deck + XL" DECK_VISUAL = True @@ -195,7 +195,23 @@ def set_touchscreen_image(self, image, x_pos=0, y_pos=0, width=0, height=0): if min(max(height, 1), self.TOUCHSCREEN_PIXEL_HEIGHT - y_pos) != height: raise IndexError("Invalid draw height {}.".format(height)) - image = bytes(image) + # The Plus XL LCD is internally portrait (800x1280). The touchscreen + # image must be rotated 90 CCW and coordinates swapped to match the + # internal frame. We rotate here (not via TOUCHSCREEN_ROTATION) because + # PIL's rotate() without expand=True crops non-square images. + from PIL import Image as PILImage + import io + + pil_img = PILImage.open(io.BytesIO(image)) + rotated = pil_img.rotate(90, expand=True) + buf = io.BytesIO() + rotated.save(buf, format="JPEG", quality=80) + image = buf.getvalue() + + int_x = y_pos + int_y = x_pos + int_w = height + int_h = width page_number = 0 bytes_remaining = len(image) @@ -206,14 +222,14 @@ def set_touchscreen_image(self, image, x_pos=0, y_pos=0, width=0, height=0): header = [ 0x02, # 0 0x0c, # 1 - x_pos & 0xff, # 2 xpos low byte - (x_pos >> 8) & 0xff, # 3 xpos high byte - y_pos & 0xff, # 4 ypos low byte - (y_pos >> 8) & 0xff, # 5 ypos high byte - width & 0xff, # 6 width low byte - (width >> 8) & 0xff, # 7 width high byte - height & 0xff, # 8 height low byte - (height >> 8) & 0xff, # 9 height high byte + int_x & 0xff, # 2 xpos low byte + (int_x >> 8) & 0xff, # 3 xpos high byte + int_y & 0xff, # 4 ypos low byte + (int_y >> 8) & 0xff, # 5 ypos high byte + int_w & 0xff, # 6 width low byte + (int_w >> 8) & 0xff, # 7 width high byte + int_h & 0xff, # 8 height low byte + (int_h >> 8) & 0xff, # 9 height high byte 1 if this_length == bytes_remaining else 0, # 10 is the last report? page_number & 0xff, # 11 pagenumber low byte (page_number >> 8) & 0xff, # 12 pagenumber high byte