From 1674283abfbb1576b317602eef04e7f2aeeda713 Mon Sep 17 00:00:00 2001 From: Your Name Date: Mon, 27 Jul 2026 06:45:22 +0000 Subject: [PATCH 1/4] ImageHelpers: preserve full image on 90/270 degree rotation PILHelper._to_native_format() rotated images with Image.rotate()'s default expand=False, which keeps the original canvas size. For a square canvas (every existing device's key images) or a 0/180 degree rotation this is a no-op, but for a non-square canvas rotated by 90 or 270 degrees it silently clips the rotated content instead of growing the canvas to fit it. This is required to correctly support the Stream Deck + XL's touch window (1200x100) and full LCD panel (1280x800) images, which Elgato's protocol requires to be rotated 90 degrees counter-clockwise before upload. No behavioural change for any existing device. --- src/StreamDeck/ImageHelpers/PILHelper.py | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/src/StreamDeck/ImageHelpers/PILHelper.py b/src/StreamDeck/ImageHelpers/PILHelper.py index f4229a60..18837ad7 100644 --- a/src/StreamDeck/ImageHelpers/PILHelper.py +++ b/src/StreamDeck/ImageHelpers/PILHelper.py @@ -40,7 +40,13 @@ def _to_native_format(image, image_format): image.thumbnail(image_format['size']) if image_format['rotation']: - image = image.rotate(image_format['rotation']) + # expand=True grows the canvas to fit the rotated content. This is a + # no-op for square images or 0/180 degree rotations (all pre-existing + # devices), but is required for devices with non-square touchscreen/ + # screen images that need a 90/270 degree rotation (e.g. Stream Deck + # + XL), otherwise the rotated content would be clipped to the + # original canvas bounds. + image = image.rotate(image_format['rotation'], expand=True) if image_format['flip'][0]: image = image.transpose(Image.FLIP_LEFT_RIGHT) From 362be0ecad3d604fcad6ef99eca60c0805e3821f Mon Sep 17 00:00:00 2001 From: Your Name Date: Mon, 27 Jul 2026 06:45:22 +0000 Subject: [PATCH 2/4] Add support for the Stream Deck + XL Adds a new StreamDeckPlusXL device class implementing the Stream Deck + XL (36 keys in a 9x4 matrix, 6 rotary encoders, a touch-capable window strip, and a full LCD panel behind the keys), and registers its VID/PID (0x0FD9 / 0x00C6) with the DeviceManager. Implemented directly from Elgato's official HID protocol documentation: https://docs.elgato.com/streamdeck/hid/stream-deck-plus-xl https://docs.elgato.com/streamdeck/hid/general Key event, touchscreen event (TAP/PRESS/FLICK) and encoder event (BTN/ROTATE) parsing follow the same on-wire layout already used by the existing Stream Deck + implementation, just sized for 36 keys / 6 encoders. Button, touchscreen-window and full-screen images are all generated dynamically via PILHelper (instead of a hardcoded blank JPEG byte array), so they are always correct for this device's declared image formats -- including the 90 degree counter-clockwise rotation this device requires for its touchscreen and screen images. Note: the VID/PID and protocol details come from Elgato's published documentation; I don't have physical hardware to test against, so real-world verification (particularly key index ordering and image orientation) from someone with the device would be very welcome. --- src/StreamDeck/DeviceManager.py | 2 + src/StreamDeck/Devices/StreamDeckPlusXL.py | 332 +++++++++++++++++++++ src/StreamDeck/ProductIDs.py | 1 + 3 files changed, 335 insertions(+) create mode 100644 src/StreamDeck/Devices/StreamDeckPlusXL.py diff --git a/src/StreamDeck/DeviceManager.py b/src/StreamDeck/DeviceManager.py index c6d506a8..8c2074f9 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 @@ -114,6 +115,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..195b242b --- /dev/null +++ b/src/StreamDeck/Devices/StreamDeckPlusXL.py @@ -0,0 +1,332 @@ +# Python Stream Deck Library +# Released under the MIT license +# +# dean [at] fourwalledcubicle [dot] com +# www.fourwalledcubicle.com +# + +# Stream Deck + XL support. +# +# Implemented directly from Elgato's official "Stream Deck HID API" maker +# documentation: +# General reference: https://docs.elgato.com/streamdeck/hid/general +# Device specific: https://docs.elgato.com/streamdeck/hid/stream-deck-plus-xl +# +# Summary of the device, per that documentation: +# - VID 0x0FD9, PID 0x00C6 +# - 36 keys, arranged as a 9 (cols) x 4 (rows) matrix +# - 6 rotary encoders ("dials"), each with a push button +# - A touch-capable window strip (1200 x 100 px, logical/pre-rotation) +# - A full LCD panel behind the keys (1280 x 800 px, logical/pre-rotation) +# - Button images, the window image and the full screen image must all be +# rotated 90 degrees counter-clockwise before being JPEG-encoded and sent + +from .StreamDeck import StreamDeck, ControlType, DialEventType, TouchscreenEventType +from ..ImageHelpers import PILHelper + + +def _dials_rotation_transform(value): + """ + Converts an unsigned byte (0-255) read off the wire into a signed tick + count, matching the device's documented INT8 encoder delta: positive for + clockwise ticks, negative for counter-clockwise ticks. + """ + if value < 0x80: + return value + else: + return -(0x100 - value) + + +class StreamDeckPlusXL(StreamDeck): + """ + Represents a physically attached Stream Deck + XL device. + """ + + KEY_COUNT = 36 + KEY_COLS = 9 + KEY_ROWS = 4 + + DIAL_COUNT = 6 + + # Button image: 112 x 112 px, rotated 90 degrees CCW before upload. + KEY_PIXEL_WIDTH = 112 + KEY_PIXEL_HEIGHT = 112 + KEY_IMAGE_FORMAT = "JPEG" + KEY_FLIP = (False, False) + KEY_ROTATION = 90 + + DECK_TYPE = "Stream Deck + XL" + DECK_VISUAL = True + DECK_TOUCH = True + + # Touchscreen "window" strip: 1200 x 100 px (logical, pre-rotation), + # rotated 90 degrees CCW before upload. + TOUCHSCREEN_PIXEL_WIDTH = 1200 + TOUCHSCREEN_PIXEL_HEIGHT = 100 + TOUCHSCREEN_IMAGE_FORMAT = "JPEG" + TOUCHSCREEN_FLIP = (False, False) + TOUCHSCREEN_ROTATION = 90 + + # Full LCD panel behind the keys: 1280 x 800 px (logical, pre-rotation), + # rotated 90 degrees CCW before upload. + SCREEN_PIXEL_WIDTH = 1280 + SCREEN_PIXEL_HEIGHT = 800 + SCREEN_IMAGE_FORMAT = "JPEG" + SCREEN_FLIP = (False, False) + SCREEN_ROTATION = 90 + + _IMG_PACKET_LEN = 1024 # Max size for a data packet when setting images + + _KEY_PACKET_HEADER = 8 + _SCREEN_PACKET_HEADER = 8 + _LCD_PACKET_HEADER = 16 + + _KEY_PACKET_PAYLOAD_LEN = _IMG_PACKET_LEN - _KEY_PACKET_HEADER + _SCREEN_PACKET_PAYLOAD_LEN = _IMG_PACKET_LEN - _SCREEN_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) + + # Generated dynamically (instead of a hardcoded byte array like the + # older devices use) since they run through the same rotation/format + # pipeline as any other image set via this library, guaranteeing they + # are always correct for this device's declared image formats. + 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") + ) + self.BLANK_SCREEN_IMAGE = PILHelper.to_native_screen_format( + self, PILHelper.create_screen_image(self, "black") + ) + + def _reset_key_stream(self): + payload = bytearray(self._IMG_PACKET_LEN) + payload[0] = 0x02 + self.device.write(payload) + + def reset(self): + # Show Logo (Report ID 0x03, Command 0x02) + payload = bytearray(32) + payload[0:2] = [0x03, 0x02] + self.device.write_feature(payload) + + def _read_control_states(self): + # Large enough to cover the biggest input report we handle: report + # ID (1) + command (1) + payload length (2) + one byte per key (36). + states = self.device.read(4 + self.KEY_COUNT) + if states is None: + return None + + # Strip the leading Report ID byte, so states[0] is now the Command. + states = states[1:] + + command = states[0] + + if command == 0x00: # Key / Button Press State Change + new_key_states = [bool(s) for s in states[3:3 + self.KEY_COUNT]] + return { + ControlType.KEY: new_key_states + } + elif command == 0x02: # Touch Screen Activity + contents_type = states[3] + + if contents_type == 0x01: + event_type = TouchscreenEventType.SHORT # TAP + elif contents_type == 0x02: + event_type = TouchscreenEventType.LONG # PRESS + elif contents_type == 0x03: + event_type = TouchscreenEventType.DRAG # FLICK + 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 command == 0x03: # Encoders State Change + contents_type = states[3] + + if contents_type == 0x01: + event_type = DialEventType.TURN # ROTATE + elif contents_type == 0x00: + event_type = DialEventType.PUSH # BTN + 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, + } + } + + return None + + def set_brightness(self, percent): + if isinstance(percent, float): + percent = int(100.0 * percent) + + percent = min(max(percent, 0), 100) + + # Set Backlight Brightness (Report ID 0x03, Command 0x08) + 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 - 1) != 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) + bytes_sent = page_number * self._KEY_PACKET_PAYLOAD_LEN + + # Update Button Image (Report ID 0x02, Command 0x07) + header = [ + 0x02, + 0x07, + key & 0xff, + 1 if this_length == bytes_remaining else 0, + this_length & 0xff, + (this_length >> 8) & 0xff, + page_number & 0xff, + (page_number >> 8) & 0xff, + ] + + 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)) + + # NOTE: x_pos/y_pos/width/height are "logical" coordinates (i.e. in + # the pre-rotation 1200x100 space), per Elgato's documentation. The + # image bytes passed in must already be rotated/encoded (this is what + # PILHelper.to_native_touchscreen_format does for you). + 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 + + # Update Partial Window Image (Report ID 0x02, Command 0x0C) + header = [ + 0x02, + 0x0c, + x_pos & 0xff, + (x_pos >> 8) & 0xff, + y_pos & 0xff, + (y_pos >> 8) & 0xff, + width & 0xff, + (width >> 8) & 0xff, + height & 0xff, + (height >> 8) & 0xff, + 1 if this_length == bytes_remaining else 0, + page_number & 0xff, + (page_number >> 8) & 0xff, + this_length & 0xff, + (this_length >> 8) & 0xff, + 0x00, + ] + + 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): + if min(max(key, 0), self.KEY_COUNT - 1) != key: + raise IndexError("Invalid key index {}.".format(key)) + + if not (0 <= r <= 255 and 0 <= g <= 255 and 0 <= b <= 255): + raise ValueError("Invalid color") + + # Fill Key / Button with Color (Report ID 0x03, Command 0x06) + payload = bytearray(32) + payload[0:6] = [0x03, 0x06, key, r, g, b] + self.device.write_feature(payload) + + def set_screen_image(self, image): + if not image: + image = self.BLANK_SCREEN_IMAGE + + image = bytes(image) + + page_number = 0 + bytes_remaining = len(image) + while bytes_remaining > 0: + this_length = min(bytes_remaining, self._SCREEN_PACKET_PAYLOAD_LEN) + bytes_sent = page_number * self._SCREEN_PACKET_PAYLOAD_LEN + + # Update Full Screen Image (Report ID 0x02, Command 0x08) + header = [ + 0x02, + 0x08, + 0x00, + 1 if this_length == bytes_remaining else 0, + this_length & 0xff, + (this_length >> 8) & 0xff, + page_number & 0xff, + (page_number >> 8) & 0xff, + ] + + 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 diff --git a/src/StreamDeck/ProductIDs.py b/src/StreamDeck/ProductIDs.py index 1bf5933f..1f47060f 100644 --- a/src/StreamDeck/ProductIDs.py +++ b/src/StreamDeck/ProductIDs.py @@ -32,6 +32,7 @@ class USBProductIDs: USB_PID_STREAMDECK_ORIGINAL_V2 = 0x006d USB_PID_STREAMDECK_PEDAL = 0x0086 USB_PID_STREAMDECK_PLUS = 0x0084 + USB_PID_STREAMDECK_PLUS_XL = 0x00c6 USB_PID_STREAMDECK_XL = 0x006c USB_PID_STREAMDECK_XL_V2 = 0x008f USB_PID_STREAMDECK_STUDIO = 0x00aa From ea9d9fb78b9197097258ffa4b929d8401b69370f Mon Sep 17 00:00:00 2001 From: Your Name Date: Mon, 27 Jul 2026 06:45:22 +0000 Subject: [PATCH 3/4] Add example script for the Stream Deck + XL Mirrors example_plus.py: demonstrates per-key images, dial push/turn callbacks, drawing on the touchscreen window, and setting the full LCD panel image. --- src/example_plus_xl.py | 101 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 101 insertions(+) create mode 100644 src/example_plus_xl.py diff --git a/src/example_plus_xl.py b/src/example_plus_xl.py new file mode 100644 index 00000000..939e99f5 --- /dev/null +++ b/src/example_plus_xl.py @@ -0,0 +1,101 @@ +#!/usr/bin/env python3 + +# Python Stream Deck Library +# Released under the MIT license +# +# + +# Example script showing some Stream Deck + XL specific functions + +import threading + +from PIL import Image, ImageDraw +from StreamDeck.DeviceManager import DeviceManager +from StreamDeck.Devices.StreamDeck import DialEventType, TouchscreenEventType +from StreamDeck.ImageHelpers import PILHelper +from StreamDeck.Transport.Transport import TransportError + + +def make_key_image(deck, color, label): + image = PILHelper.create_key_image(deck, background=color) + draw = ImageDraw.Draw(image) + draw.text((image.width / 2, image.height / 2), text=label, anchor="mm", fill="white") + return PILHelper.to_native_key_format(deck, image) + + +# callback when keys are pressed or released +def key_change_callback(deck, key, key_state): + print("Key: " + str(key) + " state: " + str(key_state)) + color = "green" if key_state else "black" + deck.set_key_image(key, make_key_image(deck, color, str(key))) + + +# callback when dials are pressed/turned +def dial_change_callback(deck, dial, event, value): + if event == DialEventType.PUSH: + print(f"Dial {dial} pushed: {value}") + + # Draw a status line on the touchscreen window showing which dial + # was pushed. + image = PILHelper.create_touchscreen_image(deck, background="black") + draw = ImageDraw.Draw(image) + draw.text((10, image.height / 2), text=f"Dial {dial} pushed!", anchor="lm", fill="white") + deck.set_touchscreen_image(PILHelper.to_native_touchscreen_format(deck, image)) + + elif event == DialEventType.TURN: + print(f"Dial {dial} turned: {value}") + + +# callback when the touchscreen window is touched +def touchscreen_event_callback(deck, evt_type, value): + if evt_type == TouchscreenEventType.SHORT: + print("Tap @ " + str(value['x']) + "," + str(value['y'])) + elif evt_type == TouchscreenEventType.LONG: + print("Press @ " + str(value['x']) + "," + str(value['y'])) + elif evt_type == TouchscreenEventType.DRAG: + print("Flick " + str(value['x']) + "," + str(value['y']) + " -> " + str(value['x_out']) + "," + str(value['y_out'])) + + +if __name__ == "__main__": + streamdecks = DeviceManager().enumerate() + + print("Found {} Stream Deck(s).\n".format(len(streamdecks))) + + for index, deck in enumerate(streamdecks): + if deck.DECK_TYPE != 'Stream Deck + XL': + print(deck.DECK_TYPE) + print("Sorry, this example only works with the Stream Deck + XL") + continue + + deck.open() + deck.reset() + + deck.set_key_callback(key_change_callback) + deck.set_dial_callback(dial_change_callback) + deck.set_touchscreen_callback(touchscreen_event_callback) + + print("Opened '{}' device (serial number: '{}')".format(deck.deck_type(), deck.get_serial_number())) + + deck.set_brightness(75) + + # Light up every key with its index number + for key in range(0, deck.KEY_COUNT): + deck.set_key_image(key, make_key_image(deck, "black", str(key))) + + # Draw a full-width background image on the LCD panel behind the keys + screen_image = PILHelper.create_screen_image(deck, background="black") + deck.set_screen_image(PILHelper.to_native_screen_format(deck, screen_image)) + + # Draw an initial message on the touchscreen window strip + touch_image = PILHelper.create_touchscreen_image(deck, background="black") + draw = ImageDraw.Draw(touch_image) + draw.text((10, touch_image.height / 2), text="Press a key or turn/push a dial...", anchor="lm", fill="white") + deck.set_touchscreen_image(PILHelper.to_native_touchscreen_format(deck, touch_image)) + + # Wait until all application threads have terminated (for this + # example, this is when all deck handles are closed). + for t in threading.enumerate(): + try: + t.join() + except (TransportError, RuntimeError): + pass From bff6d70e7bc4203c1fcb6b0074e005fc88c25f43 Mon Sep 17 00:00:00 2001 From: Your Name Date: Mon, 27 Jul 2026 06:45:36 +0000 Subject: [PATCH 4/4] README: list the Stream Deck + XL as a supported product --- README.md | 1 + 1 file changed, 1 insertion(+) diff --git a/README.md b/README.md index d24bbdd7..a7828c56 100644 --- a/README.md +++ b/README.md @@ -29,6 +29,7 @@ variants: * StreamDeck Original * StreamDeck Pedal * StreamDeck Plus +* StreamDeck Plus XL * StreamDeck Studio * StreamDeck XL