greendeck/greendeck/lib/elgato/streamdeck/__init__.py
2022-12-13 14:25:50 -06:00

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