386 lines
12 KiB
Python
386 lines
12 KiB
Python
# 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
|