diff --git a/VERSION b/VERSION index e3e1807..78bc1ab 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.9.8 +0.10.0 diff --git a/src/StreamDeck/DeviceManager.py b/src/StreamDeck/DeviceManager.py index 3ea1b3a..f159ca0 100644 --- a/src/StreamDeck/DeviceManager.py +++ b/src/StreamDeck/DeviceManager.py @@ -12,6 +12,7 @@ from .Devices.StreamDeckOriginalV2 import StreamDeckOriginalV2 from .Devices.StreamDeckXL import StreamDeckXL from .Devices.StreamDeckPedal import StreamDeckPedal +from .Devices.StreamDeckStudio import StreamDeckStudio from .Devices.StreamDeckPlus import StreamDeckPlus from .Transport import Transport from .Transport.Dummy import Dummy @@ -110,6 +111,7 @@ def enumerate(self) -> list[StreamDeck]: (USBVendorIDs.USB_VID_ELGATO, USBProductIDs.USB_PID_STREAMDECK_MINI_MK2_MODULE, StreamDeckMini), (USBVendorIDs.USB_VID_ELGATO, USBProductIDs.USB_PID_STREAMDECK_XL_V2, StreamDeckXL), (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), ] diff --git a/src/StreamDeck/Devices/StreamDeckStudio.py b/src/StreamDeck/Devices/StreamDeckStudio.py new file mode 100644 index 0000000..32da1b7 --- /dev/null +++ b/src/StreamDeck/Devices/StreamDeckStudio.py @@ -0,0 +1,212 @@ +# Python Stream Deck Library +# Released under the MIT license +# +# dean [at] fourwalledcubicle [dot] com +# www.fourwalledcubicle.com +# + +from .StreamDeck import ControlType, DialEventType, StreamDeck +from ..ImageHelpers import PILHelper + + +def _dials_rotation_transform(value): + if value < 0x80: + # Clockwise rotation + return value + else: + # Counterclockwise rotation + return -(0x100 - value) + + +class StreamDeckStudio(StreamDeck): + KEY_COUNT = 32 + KEY_COLS = 16 + KEY_ROWS = 2 + + DIAL_COUNT = 2 + + KEY_PIXEL_WIDTH = 80 + KEY_PIXEL_HEIGHT = 120 + KEY_IMAGE_FORMAT = "JPEG" + KEY_FLIP = (False, False) + KEY_ROTATION = 0 + + DECK_TYPE = "Stream Deck Studio" + DECK_VISUAL = True + + _HID_INPUT_REPORT = 0x01 + _HID_OUTPUT_REPORT_ID = 0x02 + + _DIAL_RING_CMD = 0x0F + _DIAL_KNOB_CMD = 0x10 + _DIAL_RING_SEGMENTS = 24 + + _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") + ) + + def _reset_key_stream(self): + payload = bytearray(self._IMG_PACKET_LEN) + payload[0] = self._HID_OUTPUT_REPORT_ID + 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(43) + + if states is None: + return None + + states = states[1:] + + if states[0] == 0x00: + return self._parse_key_event(states) + elif states[0] == 0x03: + return self._parse_dial_event(states) + + return None + + def _parse_key_event(self, states): + new_key_states = [bool(s) for s in states[3:35]] + return {ControlType.KEY: new_key_states} + + def _parse_dial_event(self, states): + 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 = max(0, min(percent, 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[5:]) + + def get_firmware_version(self): + version = self.device.read_feature(0x05, 32) + return self._extract_string(version[5:]) + + def set_key_image(self, key, image): + if not 0 <= key < self.KEY_COUNT: + raise IndexError(f"Invalid key index {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) + is_last = 1 if this_length == bytes_remaining else 0 + + header = [ + self._HID_OUTPUT_REPORT_ID, + 0x07, + key & 0xFF, # key index + is_last, + this_length & 0xFF, # bytecount low byte + (this_length >> 8) & 0xFF, # bytecount high byte + page_number & 0xFF, # page number low byte + (page_number >> 8) & 0xFF, # page number 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 -= this_length + page_number += 1 + + def set_touchscreen_image(self, image, x_pos=0, y_pos=0, width=0, height=0): + pass + + def set_key_color(self, key, r, g, b): + pass + + def set_screen_image(self, image): + pass + + def set_encoder_knob_color(self, encoder, rgb): + data = [ + self._HID_OUTPUT_REPORT_ID, + self._DIAL_KNOB_CMD, + encoder, + rgb[0], rgb[1], rgb[2], + ] + self.device.write(bytes(data)) + + def set_encoder_ring_color(self, encoder, rgb): + data = [ + self._HID_OUTPUT_REPORT_ID, + self._DIAL_RING_CMD, + encoder, + ] + [rgb[0], rgb[1], rgb[2]] * self._DIAL_RING_SEGMENTS + self.device.write(bytes(data)) + + def set_encoder_ring_percentage( + self, encoder, rgb, value, segment_count=21): + """ + Sets the color of a portion of the encoder ring based on a percentage + value. + Args: + encoder (int): The encoder index (0 or 1). + rgb (tuple): A tuple of (R, G, B) values for the color. + value (int): The percentage value (0-100) to fill the ring. + segment_count (int): The number of segments to light up for 100% + (default is 21). + """ + if not 0 < segment_count <= self._DIAL_RING_SEGMENTS: + raise ValueError( + f"Invalid segment count {segment_count}, " + f"must be between 1 and {self._DIAL_RING_SEGMENTS}." + ) + + segments = round(value * segment_count / 100.0) + led_data = ( + list(rgb) * segments + [0, 0, 0] * (self._DIAL_RING_SEGMENTS - segments) + ) + + if encoder == 0: + offset = self._DIAL_RING_SEGMENTS * 3 // 2 + led_data = led_data[offset:] + led_data[:offset] + + data = [self._HID_OUTPUT_REPORT_ID, self._DIAL_RING_CMD, encoder] + led_data + self.device.write(bytes(data)) diff --git a/src/StreamDeck/ProductIDs.py b/src/StreamDeck/ProductIDs.py index df7d909..fe8b38d 100644 --- a/src/StreamDeck/ProductIDs.py +++ b/src/StreamDeck/ProductIDs.py @@ -33,4 +33,5 @@ class USBProductIDs: USB_PID_STREAMDECK_PLUS = 0x0084 USB_PID_STREAMDECK_XL = 0x006c USB_PID_STREAMDECK_XL_V2 = 0x008f + USB_PID_STREAMDECK_STUDIO = 0x00aa USB_PID_STREAMDECK_XL_V2_MODULE = 0x00ba diff --git a/src/example_studio.py b/src/example_studio.py new file mode 100644 index 0000000..9a556a9 --- /dev/null +++ b/src/example_studio.py @@ -0,0 +1,169 @@ +#!/usr/bin/env python3 + +# Python Stream Deck Library +# Released under the MIT license +# +# dean [at] fourwalledcubicle [dot] com +# www.fourwalledcubicle.com +# + +# Example script showing basic library usage - updating key images with new +# tiles generated at runtime, and responding to button state change events. + +import os +import threading + +from PIL import Image, ImageDraw, ImageFont +from StreamDeck.DeviceManager import DeviceManager +from StreamDeck.ImageHelpers import PILHelper +from StreamDeck.Transport.Transport import TransportError +from StreamDeck.Devices.StreamDeck import DialEventType + +# Folder location of image assets used by this example. +ASSETS_PATH = os.path.join(os.path.dirname(__file__), "Assets") + +dial_value = 0 + + +# Generates a custom tile with run-time generated text and custom image via the +# PIL module. +def render_key_image(deck, icon_filename, font_filename, label_text): + # Resize the source image asset to best-fit the dimensions of a single key, + # leaving a margin at the bottom so that we can draw the key title + # afterwards. + icon = Image.open(icon_filename) + image = PILHelper.create_scaled_key_image(deck, icon, margins=(0, 0, 0, 0)) + + # Load a custom TrueType font and use it to overlay the key index, draw key + # label onto the image a few pixels from the bottom of the key. + draw = ImageDraw.Draw(image) + font = ImageFont.truetype(font_filename, 14) + draw.text((image.width / 2, image.height - 5), text=label_text, font=font, anchor="ms", fill="white") + + return PILHelper.to_native_key_format(deck, image) + + +# Returns styling information for a key based on its position and state. +def get_key_style(deck, key, state): + # Last button in the example application is the exit button. + exit_key_index = deck.key_count() - 1 + + if key == exit_key_index: + name = "exit" + icon = "{}.png".format("Exit") + font = "Roboto-Regular.ttf" + label = "Bye" if state else "Exit" + else: + name = "emoji" + icon = "{}.png".format("Pressed" if state else "Released") + font = "Roboto-Regular.ttf" + label = "Pressed!" if state else "Key {}".format(key) + + return { + "name": name, + "icon": os.path.join(ASSETS_PATH, icon), + "font": os.path.join(ASSETS_PATH, font), + "label": label + } + + +# Creates a new key image based on the key index, style and current key state +# and updates the image on the StreamDeck. +def update_key_image(deck, key, state): + # Determine what icon and label to use on the generated key. + key_style = get_key_style(deck, key, state) + + # Generate the custom key with the requested image and label. + image = render_key_image(deck, key_style["icon"], key_style["font"], key_style["label"]) + + # Use a scoped-with on the deck to ensure we're the only thread using it + # right now. + with deck: + # Update requested key with the generated image. + deck.set_key_image(key, image) + + +# Prints key state change information, updates rhe key image and performs any +# associated actions when a key is pressed. +def key_change_callback(deck, key, state): + # Print new key state + print("Deck {} Key {} = {}".format(deck.id(), key, state), flush=True) + rgb = (255 * (key % 3 == 0), 255 * (key % 3 == 1), 255 * (key % 3 == 2)) + deck.set_encoder_knob_color(key, rgb) + deck.set_encoder_ring_percentage(0, rgb, key * 100 / 31, 24) + deck.set_encoder_ring_percentage(1, rgb, key * 100 / 31, 24) + + # Don't try to draw an image on a touch button + if key >= deck.key_count(): + return + + # Update the key image based on the new key state. + update_key_image(deck, key, state) + # deck.set_key_image(key, None) + + # Check if the key is changing to the pressed state. + if state: + key_style = get_key_style(deck, key, state) + + # When an exit button is pressed, close the application. + if key_style["name"] == "exit": + # Use a scoped-with on the deck to ensure we're the only thread + # using it right now. + with deck: + # Reset deck, clearing all button images. + deck.reset() + + # Close deck handle, terminating internal worker threads. + deck.close() + + +def dial_change_callback(deck, dial, event, value): + global dial_value + if event == DialEventType.PUSH: + print(f"dial pushed: {dial} state: {value}") + elif event == DialEventType.TURN: + print(f"dial {dial} turned: {value}") + dial_value += value + if dial_value < 0: + dial_value = 0 + elif dial_value > 100: + dial_value = 100 + deck.set_encoder_ring_value(dial, (0, 255, 0), dial_value, 24) + + +if __name__ == "__main__": + streamdecks = DeviceManager().enumerate() + + print("Found {} Stream Deck(s).\n".format(len(streamdecks))) + + for index, deck in enumerate(streamdecks): + # This example only works with devices that have screens. + if not deck.is_visual(): + continue + + deck.open() + deck.reset() + + print("Opened '{}' device (serial number: '{}', fw: '{}')".format( + deck.deck_type(), deck.get_serial_number(), deck.get_firmware_version() + )) + + # Set initial screen brightness to 30%. + deck.set_brightness(30) + + # Set initial key images. + for key in range(deck.key_count()): + # deck.set_key_image(key, None) + update_key_image(deck, key, False) + + # Register callback function for when a key state changes. + deck.set_key_callback(key_change_callback) + deck.set_dial_callback(dial_change_callback) + + # 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