# Python Stream Deck Library # Released under the MIT license # # dean [at] fourwalledcubicle [dot] com # www.fourwalledcubicle.com # from abc import ABC from abc import abstractmethod import asyncio from enum import Enum from enum import auto from typing import Any from typing import Awaitable from typing import Callable from typing_extensions import Self from greendeck.lib.hidapi.device import Device from greendeck.lib.util import task_done_callback # def print_done(future: asyncio.Future): # print("done", future) class KeyDirection(Enum): LTR = auto() RTL = auto() class StreamDeck(ABC): """ Represents a physically attached StreamDeck device. """ KEY_COLS: int | None = None KEY_ROWS: int | None = None KEY_PIXEL_WIDTH: int | None = None KEY_PIXEL_HEIGHT: int | None = None KEY_IMAGE_FORMAT: str | None = None KEY_FLIP: tuple[bool, bool] = None KEY_ROTATION: int = None KEY_DIRECTION: KeyDirection | None = None ENCODER_COUNT: int = 0 DECK_TYPE: str | None = None DECK_VISUAL: bool | None = None BLANK_KEY_IMAGE: bytes | None = None _serial_number: str | None = None _firmware_version: str | None = None device: Device last_key_states: list[bool | None] key_callback: Callable[[Self, int, bool, bool], Awaitable[None]] mutex: asyncio.Lock read_task: asyncio.Task run_read_task: bool def __init__(self, device: Device): self.device = device self.last_key_states = [None] * self.KEY_COUNT self.read_task = None self.run_read_task = False self.key_callback = None self.mutex = asyncio.Lock() def __del__(self): """ Delete handler for the StreamDeck, automatically closing the transport if it is currently open and terminating the transport reader thread. """ if self.read_task is not None: self.read_task.cancel() @property def KEY_COUNT(self) -> int: return self.KEY_COLS * self.KEY_ROWS @property def serial_number(self) -> str: return self.device.device_info.serial_number async def handleKeyReport(self: Self, new_key_states: list[bool] | None) -> None: if new_key_states is None: return if self.key_callback is not None: for key, (old_state, new_state) in enumerate( zip(self.last_key_states, new_key_states) ): if old_state is not None and (old_state ^ new_state): if self.key_callback is not None: t = asyncio.create_task( self.key_callback(self, key, old_state, new_state), name=( f"callback for key {key} keypress " f"{old_state} → {new_state} on " f"streamdeck {self.serial_number}" ), ) t.add_done_callback(task_done_callback) self.last_key_states = new_key_states @abstractmethod async def _reset_key_stream(self: Self): """ Sends a blank key report to the StreamDeck, resetting the key image streamer in the device. This prevents previously started partial key writes that were not completed from corrupting images sent from this application. """ pass def _extract_string(self: Self, data: bytes) -> str: """ Extracts out a human-readable string from a collection of raw bytes, removing any trailing whitespace or data after the first NUL byte. """ return str(data, "ascii", "replace").partition("\0")[0].rstrip() @abstractmethod async def _read(self: Self) -> None: """ Read handler for the underlying transport, listening for button state changes on the underlying device, caching the new states and firing off any registered callbacks. """ pass async def _setup_reader(self: Self) -> None: """ Sets up the internal transport reader thread with the given callback, for asynchronous processing of HID events from the device. If the thread already exists, it is terminated and restarted with the new callback function. :param function callback: Callback to run on the reader thread. """ if self.read_task is not None: self.run_read_task = False self.read_task.cancel() await self.read_task self.run_read_task = True self.read_task = asyncio.create_task( self._read(), name=f"read task for streamdeck {self.serial_number}" ) self.read_task.add_done_callback(task_done_callback) async def open(self): """ Opens the device for input/output. This must be called prior to setting or retrieving any device state. .. seealso:: See :func:`~StreamDeck.close` for the corresponding close method. """ await self.device.open() await self._reset_key_stream() await self._setup_reader() async def close(self): """ Closes the device for input/output. .. seealso:: See :func:`~StreamDeck.open` for the corresponding open method. """ await self.device.close() async def is_open(self) -> bool: """ Indicates if the StreamDeck device is currently open and ready for use. :rtype: bool :return: `True` if the deck is open, `False` otherwise. """ return await self.device.is_open() async def connected(self) -> bool: """ Indicates if the physical StreamDeck device this instance is attached to is still connected to the host. :rtype: bool :return: `True` if the deck is still connected, `False` otherwise. """ return await self.device.connected() def id(self) -> str: """ Retrieves the physical ID of the attached StreamDeck. This can be used to differentiate one StreamDeck from another. :rtype: str :return: Identifier for the attached device. """ return self.device.path() # def key_count(self) -> int: # """ # Retrieves number of physical buttons on the attached StreamDeck device. # :rtype: int # :return: Number of physical buttons. # """ # return self.KEY_COUNT # def deck_type(self) -> str: # """ # Retrieves the model of Stream Deck. # :rtype: str # :return: String containing the model name of the StreamDeck device.. # """ # return self.DECK_TYPE # def is_visual(self) -> bool: # """ # Returns whether the Stream Deck has a visual display output. # :rtype: bool # :return: `True` if the deck has a screen, `False` otherwise. # """ # return self.DECK_VISUAL @property def key_size(self: Self) -> tuple[int, int]: return (self.KEY_PIXEL_WIDTH, self.KEY_PIXEL_HEIGHT) def convert_key_to_position(self: Self, key: int) -> tuple[int, int]: match self.KEY_DIRECTION: case KeyDirection.LTR: x = key % self.KEY_COLS y = self.KEY_ROWS - (key // self.KEY_COLS) - 1 return x, y case _: raise ValueError("unsupported key direction") def key_layout(self) -> tuple[int, int]: """ Retrieves the physical button layout on the attached StreamDeck device. :rtype: (int, int) :return (rows, columns): Number of button rows and columns. """ return self.KEY_ROWS, self.KEY_COLS def key_image_format(self) -> dict: """ Retrieves the image format accepted by the attached StreamDeck device. Images should be given in this format when setting an image on a button. .. seealso:: See :func:`~StreamDeck.set_key_image` method to update the image displayed on a StreamDeck button. :rtype: dict() :return: Dictionary describing the various image parameters (size, image format, image mirroring and rotation). """ return { "size": (self.KEY_PIXEL_WIDTH, self.KEY_PIXEL_HEIGHT), "format": self.KEY_IMAGE_FORMAT, "flip": self.KEY_FLIP, "rotation": self.KEY_ROTATION, } # def set_poll_frequency(self, hz: int) -> None: # """ # Sets the frequency of the button polling reader thread, determining how # often the StreamDeck will be polled for button changes. # A higher frequency will result in a higher CPU usage, but a lower # latency between a physical button press and a event from the library. # :param int hz: Reader thread frequency, in Hz (1-1000). # """ # self.read_poll_hz = min(max(hz, 1), 1000) def set_key_callback( self, callback: Callable[[Any, int, bool, bool], Awaitable[None]] ) -> None: """ Sets the callback function called each time a button on the StreamDeck changes state (either pressed, or released). .. note:: This callback will be fired from an internal reader thread. Ensure that the given callback function is thread-safe. .. note:: Only one callback can be registered at one time. .. seealso:: See :func:`~StreamDeck.set_key_callback_async` method for a version compatible with Python 3 `asyncio` asynchronous functions. :param function callback: Callback function to fire each time a button state changes. """ self.key_callback = callback def key_states(self) -> list[bool]: """ Retrieves the current states of the buttons on the StreamDeck. :rtype: list(bool) :return: List describing the current states of each of the buttons on the device (`True` if the button is being pressed, `False` otherwise). """ return self.last_key_states @abstractmethod async def reset(self): """ Resets the StreamDeck, clearing all button images and showing the standby image. """ pass @abstractmethod async def set_brightness(self, percent: int | float): """ Sets the global screen brightness of the StreamDeck, across all the physical buttons. :param int/float percent: brightness percent, from [0-100] as an `int`, or normalized to [0.0-1.0] as a `float`. """ pass @abstractmethod async def get_serial_number(self) -> str: """ Gets the serial number of the attached StreamDeck. :rtype: str :return: String containing the serial number of the attached device. """ pass @abstractmethod async def get_firmware_version(self) -> str: """ Gets the firmware version of the attached StreamDeck. :rtype: str :return: String containing the firmware version of the attached device. """ pass @abstractmethod async def set_key_image(self, key: int, image: bytes) -> None: """ Sets the image of a button on the StreamDeck to the given image. The image being set should be in the correct format for the device, as an enumerable collection of bytes. .. seealso:: See :func:`~StreamDeck.get_key_image_format` method for information on the image format accepted by the device. :param int key: Index of the button whose image is to be updated. :param enumerable image: Raw data of the image to set on the button. If `None`, the key will be cleared to a black color. """ pass