diff --git a/src/StreamDeck/DeviceManager.py b/src/StreamDeck/DeviceManager.py index 3d02092..61ad2e5 100644 --- a/src/StreamDeck/DeviceManager.py +++ b/src/StreamDeck/DeviceManager.py @@ -12,6 +12,7 @@ from .Devices.StreamDeckXL import StreamDeckXL from .Devices.StreamDeckPedal import StreamDeckPedal from .Devices.StreamDeckPlus import StreamDeckPlus +from .Devices.Mirabox293S import Mirabox293S from .Transport.Dummy import Dummy from .Transport.LibUSBHIDAPI import LibUSBHIDAPI from .ProductIDs import USBVendorIDs, USBProductIDs @@ -42,6 +43,8 @@ class DeviceManager: USB_PID_STREAMDECK_MK2 = 0x0080 USB_PID_STREAMDECK_PEDAL = 0x0086 USB_PID_STREAMDECK_PLUS = 0x0084 + USB_VID_MIRABOX = 0x5548 + USB_PID_MIRABOX_STREAMDOCK_293S = 0x6670 @staticmethod def _get_transport(transport): @@ -114,6 +117,7 @@ def enumerate(self): (USBVendorIDs.USB_VID_ELGATO, USBProductIDs.USB_PID_STREAMDECK_MINI_MK2, StreamDeckMini), (USBVendorIDs.USB_VID_ELGATO, USBProductIDs.USB_PID_STREAMDECK_XL_V2, StreamDeckXL), (USBVendorIDs.USB_VID_ELGATO, USBProductIDs.USB_PID_STREAMDECK_PLUS, StreamDeckPlus), + (USBVendorIDs.USB_VID_MIRABOX, USBProductIDs.USB_PID_MIRABOX_STREAMDOCK_293S, Mirabox293S) ] streamdecks = list() diff --git a/src/StreamDeck/Devices/Mirabox293S.py b/src/StreamDeck/Devices/Mirabox293S.py new file mode 100644 index 0000000..e82dc22 --- /dev/null +++ b/src/StreamDeck/Devices/Mirabox293S.py @@ -0,0 +1,200 @@ +# Python Stream Deck Library +# Released under the MIT license +# +# dean [at] fourwalledcubicle [dot] com +# www.fourwalledcubicle.com +# +# Mirabox Stream Dock 293S non-official support +# by Renato Schmidt (github.com/rescbr) + +from .StreamDeck import StreamDeck, ControlType + + +class Mirabox293S(StreamDeck): + """ + Represents a physically attached Mirabox Stream Dock 293S device. + """ + + KEY_COUNT = 18 + KEY_COLS = 6 + KEY_ROWS = 3 + + KEY_PIXEL_WIDTH = 85 # TODO: check if this is the correct value + KEY_PIXEL_HEIGHT = 85 # TODO: check if this is the correct value + KEY_IMAGE_FORMAT = "JPEG" + KEY_FLIP = (False, False) + KEY_ROTATION = 90 + + DECK_TYPE = "Mirabox Stream Dock 293S" + DECK_VISUAL = True + DECK_TOUCH = False # kind of... it could be used for the side display. + + PACKET_LENGHT = 512 + + # the side display uses key ids 0x10, 0x11, 0x12 with 80x80 images. + KEY_NUM_TO_DEVICE_KEY_ID = [0x0d, 0x0a, 0x07, 0x04, 0x01, 0x10, 0xe, 0xb, 0x08, 0x05, 0x02, 0x11, 0x0f, 0x0c, 0x09, 0x06, 0x03, 0x12] + KEY_DEVICE_KEY_ID_TO_NUM = {value: index for index, value in enumerate(KEY_NUM_TO_DEVICE_KEY_ID)} + + # see note in _read_control_states() method. + _key_triggered_last_read = False + + # 72 x 72 black JPEG + BLANK_KEY_IMAGE = [ + 0xff, 0xd8, 0xff, 0xe0, 0x00, 0x10, 0x4a, 0x46, 0x49, 0x46, 0x00, 0x01, 0x01, 0x00, 0x00, 0x01, 0x00, 0x01, 0x00, + 0x00, 0xff, 0xdb, 0x00, 0x43, 0x00, 0x08, 0x06, 0x06, 0x07, 0x06, 0x05, 0x08, 0x07, 0x07, 0x07, 0x09, 0x09, 0x08, + 0x0a, 0x0c, 0x14, 0x0d, 0x0c, 0x0b, 0x0b, 0x0c, 0x19, 0x12, 0x13, 0x0f, 0x14, 0x1d, 0x1a, 0x1f, 0x1e, 0x1d, 0x1a, + 0x1c, 0x1c, 0x20, 0x24, 0x2e, 0x27, 0x20, 0x22, 0x2c, 0x23, 0x1c, 0x1c, 0x28, 0x37, 0x29, 0x2c, 0x30, 0x31, 0x34, + 0x34, 0x34, 0x1f, 0x27, 0x39, 0x3d, 0x38, 0x32, 0x3c, 0x2e, 0x33, 0x34, 0x32, 0xff, 0xdb, 0x00, 0x43, 0x01, 0x09, + 0x09, 0x09, 0x0c, 0x0b, 0x0c, 0x18, 0x0d, 0x0d, 0x18, 0x32, 0x21, 0x1c, 0x21, 0x32, 0x32, 0x32, 0x32, 0x32, 0x32, + 0x32, 0x32, 0x32, 0x32, 0x32, 0x32, 0x32, 0x32, 0x32, 0x32, 0x32, 0x32, 0x32, 0x32, 0x32, 0x32, 0x32, 0x32, 0x32, + 0x32, 0x32, 0x32, 0x32, 0x32, 0x32, 0x32, 0x32, 0x32, 0x32, 0x32, 0x32, 0x32, 0x32, 0x32, 0x32, 0x32, 0x32, 0x32, + 0x32, 0x32, 0x32, 0x32, 0x32, 0x32, 0xff, 0xc0, 0x00, 0x11, 0x08, 0x00, 0x48, 0x00, 0x48, 0x03, 0x01, 0x22, 0x00, + 0x02, 0x11, 0x01, 0x03, 0x11, 0x01, 0xff, 0xc4, 0x00, 0x1f, 0x00, 0x00, 0x01, 0x05, 0x01, 0x01, 0x01, 0x01, 0x01, + 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x09, 0x0a, + 0x0b, 0xff, 0xc4, 0x00, 0xb5, 0x10, 0x00, 0x02, 0x01, 0x03, 0x03, 0x02, 0x04, 0x03, 0x05, 0x05, 0x04, 0x04, 0x00, + 0x00, 0x01, 0x7d, 0x01, 0x02, 0x03, 0x00, 0x04, 0x11, 0x05, 0x12, 0x21, 0x31, 0x41, 0x06, 0x13, 0x51, 0x61, 0x07, + 0x22, 0x71, 0x14, 0x32, 0x81, 0x91, 0xa1, 0x08, 0x23, 0x42, 0xb1, 0xc1, 0x15, 0x52, 0xd1, 0xf0, 0x24, 0x33, 0x62, + 0x72, 0x82, 0x09, 0x0a, 0x16, 0x17, 0x18, 0x19, 0x1a, 0x25, 0x26, 0x27, 0x28, 0x29, 0x2a, 0x34, 0x35, 0x36, 0x37, + 0x38, 0x39, 0x3a, 0x43, 0x44, 0x45, 0x46, 0x47, 0x48, 0x49, 0x4a, 0x53, 0x54, 0x55, 0x56, 0x57, 0x58, 0x59, 0x5a, + 0x63, 0x64, 0x65, 0x66, 0x67, 0x68, 0x69, 0x6a, 0x73, 0x74, 0x75, 0x76, 0x77, 0x78, 0x79, 0x7a, 0x83, 0x84, 0x85, + 0x86, 0x87, 0x88, 0x89, 0x8a, 0x92, 0x93, 0x94, 0x95, 0x96, 0x97, 0x98, 0x99, 0x9a, 0xa2, 0xa3, 0xa4, 0xa5, 0xa6, + 0xa7, 0xa8, 0xa9, 0xaa, 0xb2, 0xb3, 0xb4, 0xb5, 0xb6, 0xb7, 0xb8, 0xb9, 0xba, 0xc2, 0xc3, 0xc4, 0xc5, 0xc6, 0xc7, + 0xc8, 0xc9, 0xca, 0xd2, 0xd3, 0xd4, 0xd5, 0xd6, 0xd7, 0xd8, 0xd9, 0xda, 0xe1, 0xe2, 0xe3, 0xe4, 0xe5, 0xe6, 0xe7, + 0xe8, 0xe9, 0xea, 0xf1, 0xf2, 0xf3, 0xf4, 0xf5, 0xf6, 0xf7, 0xf8, 0xf9, 0xfa, 0xff, 0xc4, 0x00, 0x1f, 0x01, 0x00, + 0x03, 0x01, 0x01, 0x01, 0x01, 0x01, 0x01, 0x01, 0x01, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x01, 0x02, 0x03, + 0x04, 0x05, 0x06, 0x07, 0x08, 0x09, 0x0a, 0x0b, 0xff, 0xc4, 0x00, 0xb5, 0x11, 0x00, 0x02, 0x01, 0x02, 0x04, 0x04, + 0x03, 0x04, 0x07, 0x05, 0x04, 0x04, 0x00, 0x01, 0x02, 0x77, 0x00, 0x01, 0x02, 0x03, 0x11, 0x04, 0x05, 0x21, 0x31, + 0x06, 0x12, 0x41, 0x51, 0x07, 0x61, 0x71, 0x13, 0x22, 0x32, 0x81, 0x08, 0x14, 0x42, 0x91, 0xa1, 0xb1, 0xc1, 0x09, + 0x23, 0x33, 0x52, 0xf0, 0x15, 0x62, 0x72, 0xd1, 0x0a, 0x16, 0x24, 0x34, 0xe1, 0x25, 0xf1, 0x17, 0x18, 0x19, 0x1a, + 0x26, 0x27, 0x28, 0x29, 0x2a, 0x35, 0x36, 0x37, 0x38, 0x39, 0x3a, 0x43, 0x44, 0x45, 0x46, 0x47, 0x48, 0x49, 0x4a, + 0x53, 0x54, 0x55, 0x56, 0x57, 0x58, 0x59, 0x5a, 0x63, 0x64, 0x65, 0x66, 0x67, 0x68, 0x69, 0x6a, 0x73, 0x74, 0x75, + 0x76, 0x77, 0x78, 0x79, 0x7a, 0x82, 0x83, 0x84, 0x85, 0x86, 0x87, 0x88, 0x89, 0x8a, 0x92, 0x93, 0x94, 0x95, 0x96, + 0x97, 0x98, 0x99, 0x9a, 0xa2, 0xa3, 0xa4, 0xa5, 0xa6, 0xa7, 0xa8, 0xa9, 0xaa, 0xb2, 0xb3, 0xb4, 0xb5, 0xb6, 0xb7, + 0xb8, 0xb9, 0xba, 0xc2, 0xc3, 0xc4, 0xc5, 0xc6, 0xc7, 0xc8, 0xc9, 0xca, 0xd2, 0xd3, 0xd4, 0xd5, 0xd6, 0xd7, 0xd8, + 0xd9, 0xda, 0xe2, 0xe3, 0xe4, 0xe5, 0xe6, 0xe7, 0xe8, 0xe9, 0xea, 0xf2, 0xf3, 0xf4, 0xf5, 0xf6, 0xf7, 0xf8, 0xf9, + 0xfa, 0xff, 0xda, 0x00, 0x0c, 0x03, 0x01, 0x00, 0x02, 0x11, 0x03, 0x11, 0x00, 0x3f, 0x00, 0xf9, 0xfe, 0x8a, 0x28, + 0xa0, 0x02, 0x8a, 0x28, 0xa0, 0x02, 0x8a, 0x28, 0xa0, 0x02, 0x8a, 0x28, 0xa0, 0x02, 0x8a, 0x28, 0xa0, 0x02, 0x8a, + 0x28, 0xa0, 0x02, 0x8a, 0x28, 0xa0, 0x02, 0x8a, 0x28, 0xa0, 0x02, 0x8a, 0x28, 0xa0, 0x02, 0x8a, 0x28, 0xa0, 0x02, + 0x8a, 0x28, 0xa0, 0x02, 0x8a, 0x28, 0xa0, 0x02, 0x8a, 0x28, 0xa0, 0x02, 0x8a, 0x28, 0xa0, 0x02, 0x8a, 0x28, 0xa0, + 0x02, 0x8a, 0x28, 0xa0, 0x02, 0x8a, 0x28, 0xa0, 0x02, 0x8a, 0x28, 0xa0, 0x02, 0x8a, 0x28, 0xa0, 0x02, 0x8a, 0x28, + 0xa0, 0x02, 0x8a, 0x28, 0xa0, 0x02, 0x8a, 0x28, 0xa0, 0x02, 0x8a, 0x28, 0xa0, 0x02, 0x8a, 0x28, 0xa0, 0x02, 0x8a, + 0x28, 0xa0, 0x0f, 0xff, 0xd9 + ] + + def _convert_key_num_to_device_key_id(self, key): + return self.KEY_NUM_TO_DEVICE_KEY_ID[key] + + def _convert_device_key_id_to_key_num(self, key): + return self.KEY_DEVICE_KEY_ID_TO_NUM[key] + + + def _make_payload_for_report_id(self, report_id, payload_data): + payload = bytearray(self.PACKET_LENGHT + 1) + payload[0] = report_id + payload[1:len(payload_data)] = payload_data + return payload + + def _read_control_states(self): + states = [False] * self.KEY_COUNT + + # _key_triggered_last_read exists since 293S only triggers an HID event when a button is released. + # there are no key down and key up events, so we have to simulate the key being pressed and released. + # if a firmware upgrade that supports key down/up events is released, this variable can be removed from the code. + + if not self._key_triggered_last_read: + device_input_data = self.device.read(self.PACKET_LENGHT) + if device_input_data is None: + return None + + if(device_input_data.startswith(bytes([0x41, 0x43, 0x4b, 0x00, 0x00, 0x4f, 0x4b, 0x00]))): # ACK\0\0OK\0 + triggered_key = self._convert_device_key_id_to_key_num(int.from_bytes(device_input_data[9:10], 'big', signed=False)) + else: + # we don't know how to handle the response + return None + + states = [False] * self.KEY_COUNT + states[triggered_key] = True + self._key_triggered_last_read = True + else: + self._key_triggered_last_read = False + + return { + ControlType.KEY: states + } + + def _reset_key_stream(self): + self.reset() + + def reset(self): + # disconnect # CRT\0\0DIS + payload = self._make_payload_for_report_id(0x00, [0x43, 0x52, 0x54, 0x00, 0x00, 0x44, 0x49, 0x53]) + self.device.write(payload) + + # connect/ping # CRT\0\0CONNECT + payload = self._make_payload_for_report_id(0x00, [0x43, 0x52, 0x54, 0x00, 0x00, 0x43, 0x4f, 0x4e, 0x4e, 0x45, 0x43, 0x54]) + self.device.write(payload) + + # clear contents # CRT\0\0CLE #0x00 0x00 0x00 + payload = self._make_payload_for_report_id(0x00, [0x43, 0x52, 0x54, 0x00, 0x00, 0x43, 0x4c, 0x45, 0x00, 0x00, 0x00, 0xff]) + self.device.write(payload) + + def set_brightness(self, percent): + if isinstance(percent, float): + percent = int(100.0 * percent) + + percent = min(max(percent, 0), 100) + + # set brightness # CRT\0\0LIG #0x00 0x00 0x00 + payload = self._make_payload_for_report_id(0x00, [0x43, 0x52, 0x54, 0x00, 0x00, 0x4c, 0x49, 0x47, 0x00, 0x00, percent, 0x00]) + self.device.write(payload) + + def get_serial_number(self): + return self.device.serial_number() + + def get_firmware_version(self): + version = self.device.read_input(0x00, self.PACKET_LENGHT + 1) + return self._extract_string(version[1:]) + + def set_key_image(self, key, image): + if min(max(key, 0), self.KEY_COUNT) != key: + raise IndexError("Invalid key index {}.".format(key)) + + image = bytes(image or self.BLANK_KEY_IMAGE) + image_payload_page_length = self.PACKET_LENGHT + + key = self._convert_key_num_to_device_key_id(key) + + image_size_uint16_be = int.to_bytes(len(image), 2, 'big', signed=False) + + # start batch # CRT\0\0BAT #0x00 0x00 + command = bytes([0x43, 0x52, 0x54, 0x00, 0x00, 0x42, 0x41, 0x54, 0x00, 0x00]) + image_size_uint16_be + bytes([key]) + payload = self._make_payload_for_report_id(0x00, command) + self.device.write(payload) + + page_number = 0 + bytes_remaining = len(image) + while bytes_remaining > 0: + this_length = min(bytes_remaining, image_payload_page_length) + bytes_sent = page_number * image_payload_page_length + + #send data + payload = self._make_payload_for_report_id(0x00, image[bytes_sent:bytes_sent + this_length]) + self.device.write(payload) + + bytes_remaining = bytes_remaining - this_length + page_number = page_number + 1 + + # stop batch # CRT\0\0STP + payload = self._make_payload_for_report_id(0x00, [0x43, 0x52, 0x54, 0x00, 0x00, 0x53, 0x54, 0x50]) + self.device.write(payload) + + + + 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 diff --git a/src/StreamDeck/ProductIDs.py b/src/StreamDeck/ProductIDs.py index 68b8186..1bb0e0d 100644 --- a/src/StreamDeck/ProductIDs.py +++ b/src/StreamDeck/ProductIDs.py @@ -12,6 +12,7 @@ class USBVendorIDs: """ USB_VID_ELGATO = 0x0fd9 + USB_VID_MIRABOX = 0x5548 class USBProductIDs: @@ -29,3 +30,5 @@ class USBProductIDs: USB_PID_STREAMDECK_PEDAL = 0x0086 USB_PID_STREAMDECK_MINI_MK2 = 0x0090 USB_PID_STREAMDECK_PLUS = 0x0084 + USB_PID_MIRABOX_STREAMDOCK_293S = 0x6670 + diff --git a/src/StreamDeck/Transport/LibUSBHIDAPI.py b/src/StreamDeck/Transport/LibUSBHIDAPI.py index 11d0948..edf53a1 100644 --- a/src/StreamDeck/Transport/LibUSBHIDAPI.py +++ b/src/StreamDeck/Transport/LibUSBHIDAPI.py @@ -133,6 +133,9 @@ class hid_device_info(ctypes.Structure): self.HIDAPI_INSTANCE.hid_get_feature_report.argtypes = [ctypes.c_void_p, ctypes.POINTER(ctypes.c_char), ctypes.c_size_t] self.HIDAPI_INSTANCE.hid_get_feature_report.restype = ctypes.c_int + self.HIDAPI_INSTANCE.hid_get_input_report.argtypes = [ctypes.c_void_p, ctypes.POINTER(ctypes.c_char), ctypes.c_size_t] + self.HIDAPI_INSTANCE.hid_get_input_report.restype = ctypes.c_int + self.HIDAPI_INSTANCE.hid_write.argtypes = [ctypes.c_void_p, ctypes.POINTER(ctypes.c_char), ctypes.c_size_t] self.HIDAPI_INSTANCE.hid_write.restype = ctypes.c_int @@ -198,6 +201,7 @@ def enumerate(self, vendor_id=None, product_id=None): 'path': current_device.contents.path.decode('utf-8'), 'vendor_id': current_device.contents.vendor_id, 'product_id': current_device.contents.product_id, + 'serial_number': current_device.contents.serial_number }) current_device = current_device.contents.next @@ -298,6 +302,45 @@ def get_feature_report(self, handle, report_id, length): # We read an extra byte (as expected). Just return the first length requested bytes. return data.raw[:length] + def get_input_report(self, handle, report_id, length): + """ + Retrieves a HID Input report from an open HID device. + + :param Handle handle: Device handle to access. + :param int report_id: Report ID of the report being read. + :param int length: Maximum length of the Input report to read. + + :rtype: bytearray() + :return: Array of bytes containing the read Input report. The + first byte of the report will be the Report ID of the + report that was read. + """ + + # We may need to oversize our read due a bug in some versions of + # HIDAPI. Only applied on Mac systems, as this will cause other + # issues on other platforms. + read_length = (length + 1) if self.platform_name == 'Darwin' else length + + data = ctypes.create_string_buffer(read_length) + data[0] = report_id + + with self.mutex: + if not handle: + raise TransportError("No HID device.") + + result = self.hidapi.hid_get_input_report(handle, data, len(data)) + + if result < 0: + raise TransportError("Failed to read input report (%d)" % result) + + if length < read_length and result == read_length: + # Mac HIDAPI 0.9.0 bug, we read one less than we expected (not including report ID). + # We requested an over-sized report, so we actually got the amount we wanted. + return data.raw + + # We read an extra byte (as expected). Just return the first length requested bytes. + return data.raw[:length] + def write(self, handle, data): """ Writes a HID Out report to an open HID device. @@ -388,6 +431,9 @@ def vendor_id(self): def product_id(self): return self.device_info['product_id'] + + def serial_number(self): + return self.device_info['serial_number'] def path(self): return self.device_info['path'] @@ -399,6 +445,10 @@ def write_feature(self, payload): def read_feature(self, report_id, length): with self.mutex: return self.hidapi.get_feature_report(self.device_handle, report_id, length) + + def read_input(self, report_id, length): + with self.mutex: + return self.hidapi.get_input_report(self.device_handle, report_id, length) def write(self, payload): with self.mutex: diff --git a/src/StreamDeck/Transport/Transport.py b/src/StreamDeck/Transport/Transport.py index da161a4..52b9c21 100644 --- a/src/StreamDeck/Transport/Transport.py +++ b/src/StreamDeck/Transport/Transport.py @@ -135,6 +135,21 @@ def read_feature(self, report_id, length): """ pass + @abstractmethod + def read_input(self, report_id, length): + """ + Reads a HID Input report from the open HID device. + + :param int report_id: Report ID of the report being read. + :param int length: Maximum length of the Input report to read. + + :rtype: list(byte) + :return: List of bytes containing the read Feature report. The + first byte of the report will be the Input ID of the + report that was read. + """ + pass + @abstractmethod def write(self, payload): """